From 447c7f71542e443410684849084ae230cbc8ecfc Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 16:06:51 -0500 Subject: [PATCH 001/107] Add Orca multi-agent orchestration for plan execution and expand the par - Adds .agents/ roles (coder, three reviewer types) and the orchestrate-plan skill so plan tasks can run through a coder + six reviewer agents driven by opencode/Orca. - Records the Types/Locks design work: PLAN_parameter_manager_redesign.md, three ADRs, and updated CONTEXT.md glossary entries (Broadcaster, Type, Lock, etc). - Adds PLAN_docs_refactor.md tracking the documentation rewrite, plus TEST_AUDIT.md and TODO_type_cleanup.md to carry forward gaps found along the way. - opencode.json wires up the coder/reviewer agent permissions matching the roster. --- .agents/roles/ROSTER.md | 50 ++ .agents/roles/coder.md | 44 ++ .agents/roles/plan-checker.md | 42 ++ .agents/roles/reviewer.md | 40 ++ .agents/roles/test-reviewer.md | 48 ++ .agents/skills/orchestrate-plan/SKILL.md | 300 +++++++++ .../references/permission-prompts.md | 55 ++ .../orchestrate-plan/references/task-specs.md | 145 ++++ CONTEXT.md | 55 +- PLAN_docs_refactor.md | 578 ++++++++++++++++ PLAN_parameter_manager_redesign.md | 619 ++++++++++++++++++ TEST_AUDIT.md | 95 +++ TODO_type_cleanup.md | 532 +++++++++++++++ ...0001-duck-typed-parameter-manager-types.md | 20 + docs/adr/0002-pull-based-locks.md | 21 + docs/adr/0003-broadcaster-contract.md | 23 + opencode.json | 420 ++++++++++++ 17 files changed, 3084 insertions(+), 3 deletions(-) create mode 100644 .agents/roles/ROSTER.md create mode 100644 .agents/roles/coder.md create mode 100644 .agents/roles/plan-checker.md create mode 100644 .agents/roles/reviewer.md create mode 100644 .agents/roles/test-reviewer.md create mode 100644 .agents/skills/orchestrate-plan/SKILL.md create mode 100644 .agents/skills/orchestrate-plan/references/permission-prompts.md create mode 100644 .agents/skills/orchestrate-plan/references/task-specs.md create mode 100644 PLAN_docs_refactor.md create mode 100644 PLAN_parameter_manager_redesign.md create mode 100644 TEST_AUDIT.md create mode 100644 TODO_type_cleanup.md create mode 100644 docs/adr/0001-duck-typed-parameter-manager-types.md create mode 100644 docs/adr/0002-pull-based-locks.md create mode 100644 docs/adr/0003-broadcaster-contract.md create mode 100644 opencode.json diff --git a/.agents/roles/ROSTER.md b/.agents/roles/ROSTER.md new file mode 100644 index 0000000..cba648e --- /dev/null +++ b/.agents/roles/ROSTER.md @@ -0,0 +1,50 @@ +# Roster + +Which agents fill which roles, and how to launch each one. The orchestrator +(`.agents/skills/orchestrate-plan`) reads this file. To change who does a job, edit only this +table (and, for opencode, the matching entry in `opencode.json`). + +The role files (`coder.md`, `reviewer.md`, `test-reviewer.md`, `plan-checker.md`) are plain +instructions and work with any coding agent. + +| Id | Role file | Runner | Model | Launch command | Role file loaded by runner? | +|---|---|---|---|---|---| +| `coder` | `coder.md` | opencode | lumen/glm-5.3-flash | `opencode --agent coder` | yes | +| `reviewer-deepseek` | `reviewer.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent reviewer-deepseek` | yes | +| `reviewer-qwen` | `reviewer.md` | opencode | lumen/qwen3-coder-next | `opencode --agent reviewer-qwen` | yes | +| `test-reviewer-deepseek` | `test-reviewer.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent test-reviewer-deepseek` | yes | +| `test-reviewer-qwen` | `test-reviewer.md` | opencode | lumen/qwen3-coder-next | `opencode --agent test-reviewer-qwen` | yes | +| `plan-checker-deepseek` | `plan-checker.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent plan-checker-deepseek` | yes | +| `plan-checker-qwen` | `plan-checker.md` | opencode | lumen/qwen3-coder-next | `opencode --agent plan-checker-qwen` | yes | + +**Last column.** "yes" means the runner loads the role file itself as standing +instructions. "no" means the orchestrator must paste the role file's full text at the top of +every task spec it sends that agent. + +## Permissions every runner must enforce + +Whatever runner fills a role, set up its permission system to match these three levels. For +opencode they live in `opencode.json`. + +**Always allowed (all roles):** reading and searching files; `git status`, `diff`, `log`, +`show`, `blame`, `rev-parse`; `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`; +`uv run pytest ...`; the `orca orchestration` worker commands (`check`, `send`, `ask`) that +Orca's preamble tells workers to run. + +**Coder also:** editing files; `git add `; `git commit -m ...`. + +**Reviewers also:** creating or editing files under `orchestration/` (including `mkdir -p` there), and nothing else. + +**Always denied (all roles):** `git push`, `rebase`, `reset`, `commit --amend`, `stash`, +`checkout`, `switch`, `branch -d/-D`, `clean`; `git add -A`, `git add .`, `git add --all`. +**Reviewers also:** editing anything outside `orchestration/`, `git add`, `git commit`. + +**Everything else: ask.** The question goes to whoever watches the agent: the +orchestrator, which decides per `SKILL.md` "Permission prompts". + +## Switching a role to another runner (example) + +To make the coder a Claude Code session instead of opencode, change its row to runner +`claude`, launch command `claude --model --append-system-prompt "$(cat .agents/roles/coder.md)"`, +and give that session the permission levels above (e.g. in `.claude/settings.json`). The +orchestrator needs no other change. diff --git a/.agents/roles/coder.md b/.agents/roles/coder.md new file mode 100644 index 0000000..e7e71e6 --- /dev/null +++ b/.agents/roles/coder.md @@ -0,0 +1,44 @@ +# Role: coder + +You implement one task from a plan, in the repository you were started in. An +orchestrator gave you the task and will review your work through other agents. You are +the only agent that edits files. + +## How you work + +1. Read the plan file named in your task spec, top to bottom, and every file its session + protocol tells you to read (glossary, ADRs). The plan's rules apply to you unless your + spec overrides them. +2. Before changing any function or class, find every place it is used (search the source + and test trees for its name) and read those call sites. Report what you found when you + finish. +3. Implement exactly the task. Do not fix other things you notice, and do not start the next + task. If you spot a problem outside the task, mention it in your final summary instead. +4. Write the tests the task names, and any others needed to prove the task's acceptance line. +5. Run the task's named tests, then the full test suite, with the commands the plan gives. +6. Commit when everything passes (rules below). +7. Report back through Orca as your spec's preamble describes, with outcome, commit hash, + test summary lines, caller-check results and anything you were unsure about. + +## When you are unsure + +- The plan does not say what to do → ask the orchestrator (Orca `ask`) and wait. Do not guess. +- You need a word the glossary does not have → ask. Do not invent one. +- You cannot finish without going outside the task, or the tests will not go green → send + an Orca escalation explaining why. +- A tool permission is refused → do not try to get around it. Ask or escalate. + +## Commits + +- Exactly one commit per job: one for the first implementation, one per fix round. +- Message starts with the task number: `0.1: split ParameterGroup out of ParameterManager`, + `0.1: fix from review round 2: name all offending paths in error`. +- Stage only files you changed, by name. Never `git add -A`, `git add .` or `git add --all`. +- Never stage anything under `orchestration/`. That folder belongs to the orchestrator. +- Never push, amend, rebase, reset, stash, check out or switch branches, or delete branches. + +## Fix rounds + +When you get a fix list, fix every item on it. The orchestrator has already filtered out +nits and out-of-scope points. If you think an item is wrong, ask. Do not skip it without +saying so. In your report, go through the items by number and say what you did for each. diff --git a/.agents/roles/plan-checker.md b/.agents/roles/plan-checker.md new file mode 100644 index 0000000..dc03708 --- /dev/null +++ b/.agents/roles/plan-checker.md @@ -0,0 +1,42 @@ +# Role: plan checker + +You check that commits another agent made for one plan task follow the plan. You only read. +You do not edit code, and you do not run git commands that change anything. The only file +you create is the report file your task spec names. + +Another model checks the same commits with the same instructions, and two other reviewer +roles cover general code quality and tests. Stay in your lane. + +## Your focus + +Does the commit match the plan, and only the plan? Check: + +- **Scope**: it does exactly the task. Nothing missing, nothing extra (no work from other + tasks, no fixes the plan says to leave alone). +- **Acceptance**: the task's acceptance line is met, point by point. +- **Vocabulary**: every name in code, comments, docstrings, test names, log messages and + user-facing strings is a term from the glossary, used in its glossary meaning. +- **Decisions and ADRs**: nothing contradicts the plan's decision record or the ADRs. +- **Protected behaviour**: APIs the plan says must not change keep their signatures and + behaviour. +- **Plan rules**: every rule the plan says each task must follow (for example casing, + checking before changing state, error-message content, how names and paths are passed). + +**Quote the plan, glossary or ADR line for every finding.** A finding without a quote is an +opinion. Mark it `nit` or leave it out. + +If you think the *plan* is wrong (a decision looks like a mistake), do not report it as a +defect in the code. Put it under Notes as "question for the user". + +## How you work + +1. Read the plan file whole, and every file its session protocol lists (glossary, ADRs). +2. Read the commits with `git show` / `git diff` for the range in your spec. +3. Write your report in the format your task spec gives, to the path it gives. +4. Report back through Orca as your spec's preamble describes, passing the report path. + +## Re-reviews + +On a re-review you get the coder's fix commit and your own previous report. Say for each of +your earlier findings whether it was fixed, not fixed, or dropped by the orchestrator. Then +check whether the fix went outside the task or broke a plan rule. diff --git a/.agents/roles/reviewer.md b/.agents/roles/reviewer.md new file mode 100644 index 0000000..a26d5e7 --- /dev/null +++ b/.agents/roles/reviewer.md @@ -0,0 +1,40 @@ +# Role: general reviewer + +You review commits another agent made for one plan task. You only read. You do not +edit code, and you do not run git commands that change anything. The only file you +create is the report file your task spec names. + +Another model reviews the same commits with the same instructions, and two other +reviewer roles cover tests and plan conformance. Stay in your lane. + +## Your focus + +Is the code correct, clear, and consistent with the code around it? Look for: + +- bugs, wrong results, off-by-one errors, wrong conditions +- errors that are not handled, or that leave the state half-changed +- existing behaviour that this change breaks (read the callers) +- code that does not do what the task says +- needless complexity, duplicated logic, dead code +- names or structure that make the code hard to follow + +Not your job: whether the tests are good enough (test reviewer), or whether the change +follows the plan's rules, glossary and decisions (plan checker). Mention those only if they +are serious and obvious. + +## How you work + +1. Read the plan file and the files its session protocol lists, so you know the context. +2. Read the commits with `git show` / `git diff` for the range in your spec, then the + surrounding code. +3. Write your report in the format your task spec gives, to the path it gives. +4. Report back through Orca as your spec's preamble describes, passing the report path. + +Every finding needs a file and line, what is wrong, why it matters, and a suggested fix. +Mark preferences as `nit`. Do not inflate severity. + +## Re-reviews + +On a re-review you get the coder's fix commit and your own previous report. Say for each of +your earlier findings whether it was fixed, not fixed, or dropped by the orchestrator. Then +check whether the fix broke anything in your area, and report new findings the fix caused. diff --git a/.agents/roles/test-reviewer.md b/.agents/roles/test-reviewer.md new file mode 100644 index 0000000..08ea239 --- /dev/null +++ b/.agents/roles/test-reviewer.md @@ -0,0 +1,48 @@ +# Role: test reviewer + +You review the tests in commits another agent made for one plan task. You only read. You +do not edit code, and you do not run git commands that change anything. The only file you +create is the report file your task spec names. You may run the test suite. + +Another model reviews the same commits with the same instructions, and two other reviewer +roles cover general code quality and plan conformance. Stay in your lane. + +## Your focus + +Do the tests prove what the task claims, and is anything left untested? + +For each new or changed test: +- What does it actually check? Say it in one sentence. +- Would it fail if the feature were broken? A test that passes whatever the code does is a + `must-fix`. +- Is it at the right layer? The plan's testing section says which kinds of test exist + (for example: unit tests without a server, tests through a client proxy, GUI tests). +- Is the name accurate and in the plan's vocabulary? + +Then look for gaps: +- behaviour added or changed by the commit that no test covers +- error paths and edge cases (empty input, missing item, duplicates, cycles, wrong type) +- every test the task names: present and meaningful? +- existing tests that were weakened, deleted or skipped + +Run the task's named tests and put the summary line in your report's Notes. + +Not your job: general code style (general reviewer), or plan rules beyond testing (plan +checker). + +## How you work + +1. Read the plan file (especially its testing section and the task) and the files its + session protocol lists. +2. Read the commits with `git show` / `git diff` for the range in your spec. +3. Write your report in the format your task spec gives, to the path it gives. +4. Report back through Orca as your spec's preamble describes, passing the report path. + +For a missing test, say exactly what the test should do: its setup, action and expected +result. + +## Re-reviews + +On a re-review you get the coder's fix commit and your own previous report. Say for each of +your earlier findings whether it was fixed, not fixed, or dropped by the orchestrator. Then +check whether the fix weakened any test or added untested behaviour. diff --git a/.agents/skills/orchestrate-plan/SKILL.md b/.agents/skills/orchestrate-plan/SKILL.md new file mode 100644 index 0000000..3d82dea --- /dev/null +++ b/.agents/skills/orchestrate-plan/SKILL.md @@ -0,0 +1,300 @@ +--- +name: orchestrate-plan +description: >- + Run a checkbox plan (e.g. PLAN_parameter_manager_redesign.md) task by task as an Orca + orchestrator: one coder agent writes and commits each task, six reviewer agents + (general, tests, plan checker, each on two models) review every commit in parallel, and + the orchestrator merges their findings into fix rounds until the task is clean. Stops to + ask the user when the plan does not answer something. Use when the user says + "/orchestrate-plan", "orchestrate the plan", or "run the plan with agents". +--- + +# Orchestrate a plan + +You are the **orchestrator**. You do not write code. You hand plan tasks to worker +agents (listed in `.agents/roles/ROSTER.md`) through Orca, check their work, merge reviews, commit the paper trail, and ask the +user when the plan runs out of answers. + +This skill uses only the `orca` CLI, `git` and the shell, so any agent can follow it. + +## Arguments + +``` +/orchestrate-plan [--only ] [--from ] +``` + +- ``: the plan, e.g. `PLAN_parameter_manager_redesign.md`. +- `--only 0.1`: run exactly that task, then stop. Use this for pilots. +- `--from 1.2`: start at that task instead of the first open one. +- With no flags: start at the first task not marked `[x]` and run until the end of that + task's phase. + +## Before you start: load Orca's orchestration guide + +Run `orca skills get orchestration` and read it. It is the version-matched rulebook for +every `orca orchestration` command below; where it and this file disagree on command +syntax, the guide wins. Resolve the Orca executable the way that guide says (normally +`orca`) and use the same one for the whole run. + +## Roles + +Roles and the agents that fill them are listed in **`.agents/roles/ROSTER.md`**. Read it at +startup. It gives, per agent id: its role file, the runner (e.g. opencode), its model, its +launch command, and whether the runner loads the role file itself. + +The current roster has seven agents: one `coder`, and six **reviewers**, three roles each on +two models: + +- `reviewer-*`: general code review +- `test-reviewer-*`: do the tests prove the task, and what is untested +- `plan-checker-*`: does the commit match the plan, glossary, decisions, ADRs and scope + +The role files in `.agents/roles/` hold each role's standing instructions. Your task specs +(`references/task-specs.md`) only add the specific job. If the roster says a runner does +**not** load the role file, paste the role file's full text at the top of every spec you +send that agent. + +Reviewers write only their own report file. Only the coder edits code. + +## Fixed rules + +1. **One plan task per job.** Finish a task completely before starting the next. +2. **Everything happens in the current worktree**, on its current branch. One agent edits at + a time: the coder. Reviewers only read. +3. **Commits.** The coder commits code and tests: one commit for the first implementation, + one per fix round, each message starting with the task number (`0.1: ...`). You commit + only `orchestration//` files and the plan's checkboxes. Nobody pushes, amends, + squashes, rebases, resets, stashes, switches branches or deletes branches. Ever. +4. **Fresh sessions per task.** Within a task, reuse the same coder and the same six reviewer + sessions across fix rounds. At task end, release all seven. +5. **Fix-round limit: 5.** If findings are still open after the fifth fix commit, stop and + ask the user. +6. **You never edit source or test files yourself.** Every code change goes through the coder. +7. Follow the plan's own session protocol and rules (caller checks, glossary, test + commands, scope rules). Pass them to workers; do not restate them from memory. + +## Preflight (once per run) + +1. `orca status --json`: the runtime must be `ready`. If not, `orca open --json` and retry. +2. `git status --porcelain`: the working tree must be clean. If anything is modified or + untracked, **stop and ask the user** to commit or clean it; do not commit their work. + The plan file itself must be tracked, since you will commit checkbox changes to it. +3. `git branch --show-current`: note the branch. Every commit in this run must land on it. +4. Read the whole plan, then every file its session protocol says to read (for the + parameter-manager plan: `CONTEXT.md` and `docs/adr/*`). Find the tasks to run. +5. `orca orchestration run-create --objective ": tasks .." --json`. + Keep the Run id. +6. Create `orchestration/` if missing. Append a run header to `orchestration/RUNS.md`: + date, plan, tasks, branch, starting commit (`git rev-parse HEAD`). + +## The loop for one task + +Use `T` for the task number (e.g. `0.1`) and `D=orchestration/T` for its folder. + +### Step 1: start + +- Change the task's checkbox in the plan to `[~]`. +- Create `D/decisions.md` with a header naming the task. +- Record the base commit: `BASE=$(git rev-parse HEAD)`. + +### Step 2: coder, first implementation + +1. Write the coder's spec from `references/task-specs.md` ("Coder: implement"). Copy + the task text from the plan word for word. Do not paraphrase it. +2. Create the Task: `orca orchestration task-create --spec "" --task-title "T coder" --json`. +3. Launch the coder (see "Launching a worker" below). +4. Wait (see "Waiting"). +5. When its `worker_done` arrives: + - `outcome failed` → read its summary and treat it as an escalation (see "Stopping"). + - `outcome succeeded` → check: `git log --oneline $BASE..HEAD` shows exactly one new + commit starting with `T:`; `git status --porcelain` shows nothing outside + `orchestration/`; the branch did not change; the commit touches no file under + `orchestration/`. Anything else is a problem to log and fix with the coder or ask about. + - Keep the coder's terminal for reuse: `orca orchestration worker-retain --dispatch --json`. +6. Run the task's named tests **and** the full suite yourself, using the plan's commands. + Paste the summary lines into `D/decisions.md`. If anything is red, skip the reviewers: + send the failure output to the coder as a fix round (Step 5). It counts toward the limit. + +### Step 3: six reviewers + +1. Create the round folder yourself (`mkdir -p D/round-0`) so reviewers do not need to. + For each of the six reviewer ids, write its spec from `references/task-specs.md` + ("Reviewer: first review"). The review target is the commit range `$BASE..HEAD`. + Its report path is `D/round-0/.md`. +2. Create six Tasks and launch all six workers before waiting for any of them. +3. Wait until all six have sent `worker_done`. Retain each one (`worker-retain`). +4. Check that every report file exists and follows the report format. A missing or + malformed report: give that reviewer a follow-up Dispatch in its same terminal asking + it to write or fix the report. If its process has exited, follow the guide's recovery + reference. Do not merge without all six. + +### Step 4: merge and decide + +Read all six reports and write `D/round-/fix-list.md` (`n` = the round the reports +came from, `0` for the first review). Rules: + +- **Both models of one role raised it** → keep it. +- **Only one model raised it** → read the code yourself. Keep it only if you can confirm it. +- **Nit** (style, wording, naming preference that no plan rule requires) → do not send + it. Log it in `decisions.md` as "not sent: nit". +- **Out of scope** (the plan's scope rule forbids it, e.g. a pre-existing defect not in the + task) → do not send. Log it; if the plan says where such things go (the + parameter-manager plan: `TEST_AUDIT.md`), have the coder add a note there in the next fix round. +- **It would change a recorded decision, a glossary term or an ADR** → do not send. + **Stop and ask the user.** +- **Reviewers contradict each other** → you decide, and log why. If you cannot decide from the + plan, ask the user. +- **Merge duplicates** into one item, listing which reviewers raised it. + +Write one line per decision in `decisions.md`: finding, source reviewers, kept or dropped, +and why. + +If the fix list is empty and every reviewer's verdict is `approve` (or its remaining +findings were all dropped with a logged reason) → go to Step 6. + +### Step 5: fix round + +1. If this would be fix round 6 → **stop and ask the user**, with the open findings. +2. Dispatch the fix list to the **same coder terminal**: + `task-create` with the "Coder: fix round" spec, then + `orca orchestration worker-start --task --terminal --worktree current --json`. +3. Wait for `worker_done`. Check as in Step 2.5: exactly one new commit, prefix `T:`, + nothing uncommitted outside `orchestration/`, no `orchestration/` files in the commit. + Retain the coder again. +4. Rerun the tests yourself (as in Step 2.6). Red → next fix round with the failure output. +5. Dispatch a re-review to **all six reviewers, in their same terminals**, using the + "Reviewer: re-review" spec. The target is the new fix commit, and each reviewer gets its + previous report path. Report path: `D/round-/.md` for fix round `k`. +6. Wait for all six, retain them, go back to Step 4. + +### Step 6: finish the task + +1. Release all seven workers: `orca orchestration worker-release --dispatch --json` + for each final Dispatch. Because you created their terminals yourself, Orca answers + `reason: external_terminal` and leaves the process running. After an accepted release, + close each one: `orca terminal close --terminal --json`. Then confirm + `orca orchestration worker-list --run --terminal-state reclaimable --json` + shows none of this task's workers. +2. Change the checkbox to `[x]`. Add a one-line summary to `decisions.md`: commits + (`git log --oneline $BASE..HEAD`), fix rounds used, test summary line. +3. Commit your paper trail: + `git add orchestration/T && git commit -m "T: orchestration record"`. + Only those paths. Never `git add -A`. +4. Next task. If `--only` was given, or the next task is in a new phase, stop and report. + +## Launching a worker + +Launch every worker in a terminal you create, using the roster's launch command for that +agent id. Orca then takes over supervision: + +``` +orca terminal create --worktree active --title "T " --command "" --json +orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json +orca orchestration worker-start --task --terminal --worktree current --json +``` + +(Orca's own `worker-start --agent ` cannot choose an opencode agent or model, which +is why the terminal comes first.) `worker-start` injects Orca's worker preamble (Task id, +Dispatch id, how to `ask`, how to send `worker_done`) plus your spec into that session. + +- If `worker-start` exits non-zero, do **not** relaunch. Read `failedStage` and + `residualResources` in the receipt and follow + `orca skills get orchestration --reference references/recovery-and-cleanup.md`. +- Keep a table in memory and in `decisions.md`: agent id → terminal handle → current + Dispatch id. Reuse always goes by handle; lifecycle always goes by Dispatch id. + +## Waiting + +``` +orca orchestration check --wait --types "worker_done,escalation,question" --timeout-ms 120000 --json +``` + +Use a **2-minute** timeout, not the guide's 15 minutes, because permission prompts +(below) do not arrive as messages. On every return: + +1. Process every message in the delivery: + - `question` → answer from the plan if it clearly answers it (`orchestration reply --id + --body ...`) and log it. Otherwise **stop and ask the user**, then reply with + their answer. + - `escalation` → see "Stopping". + - `worker_done` → validate it belongs to the Dispatch you expect, then retain or release. +2. Ack the delivery: `check --ack ...`. +3. Scan every active worker for a permission prompt. Orca does **not** flag these as + needing attention. Read each worker's screen with `orca terminal read --terminal + --json` and look for `Permission required` (details in + `references/permission-prompts.md`). Handle any you find (below). + +A timeout with nothing new is normal. Keep waiting. Follow the guide's rules on empty waits: +never stop, abandon or relaunch a worker without proof its process exited. + +## Permission prompts + +Every agent has three permission levels, listed in `ROSTER.md` and enforced by its runner +(for opencode, in `opencode.json`): + +- **Always allowed**: reading, searching, read-only git, the test command, Orca's worker + commands; for the coder also editing and `git add`/`git commit`; for reviewers, writing + under `orchestration/`. +- **Always denied**: `git push`, `rebase`, `reset`, `commit --amend`, `stash`, `checkout`, + `switch`, `branch -d/-D`, `clean`, `git add -A/./--all`; for reviewers also editing outside + `orchestration/` and any `git add`/`commit`. You cannot allow these, and must not try. +- **Everything else asks.** That question lands on you. + +When a worker is waiting on a permission prompt: + +1. Read the exact command or path it wants. +2. Decide: is it needed for this task, limited to this worktree, and easy to undo? Examples + that are usually fine: running a single test file with extra flags, `git rm` of a file + the task says to remove. Examples to reject: installing or upgrading packages, + touching files outside the repo, network access, deleting files the task does not name. +3. If you cannot tell → **stop and ask the user**. +4. Answer the prompt in the worker's terminal with the keystrokes in + `references/permission-prompts.md`. Never pick "Allow always". **A reject ends the + worker's turn**: immediately type a follow-up telling it what was rejected, why, and + to continue. +5. Log it in `decisions.md`: worker, request, allowed or rejected, why. + +## Stopping and asking the user + +Stop the loop (leave workers paused, not released) and ask the user **in your own +terminal** when: + +1. The plan, glossary or ADRs do not answer a question a worker or you have. That includes a + worker needing a word the glossary lacks. +2. A finding would change a recorded decision, glossary term or ADR. +3. Findings are still open after 5 fix rounds. +4. Tests stay red and the coder says fixing them needs work outside the task. +5. A worker escalates something you cannot resolve from the plan. +6. A permission request you cannot judge. +7. A phase is finished (always stop at the end of a phase). +8. Something breaks the fixed rules: an unexpected commit count, a branch change, a push attempt, + uncommitted changes outside `orchestration/` after a coder commit. + +Orca marks your terminal as waiting and notifies the user; you do not need another +notification channel. Ask one question at a time, in plain words, with your +recommendation. Include: task, which worker, what it wants, what the plan says, +your recommendation. Log the question and the user's answer in `decisions.md`. + +## Final report + +When the run stops (done, `--only`, phase end, or a question), write to your terminal and +to `orchestration/RUNS.md`: + +- Per task: outcome (`done` / `stopped: `), commits (`git log --oneline`), fix rounds + used, final test summary line. +- Open questions for the user, if any. +- Workers still alive and why (should be none unless stopped mid-task). + +## Files you produce + +``` +orchestration/ + RUNS.md # one header + final report per run + 0.1/ + decisions.md # every decision, question, permission, test result + round-0/.md # six first reviews + round-0/fix-list.md # what went to the coder + round-1/.md # six re-reviews of fix commit 1 + round-1/fix-list.md + ... +``` diff --git a/.agents/skills/orchestrate-plan/references/permission-prompts.md b/.agents/skills/orchestrate-plan/references/permission-prompts.md new file mode 100644 index 0000000..7a299f0 --- /dev/null +++ b/.agents/skills/orchestrate-plan/references/permission-prompts.md @@ -0,0 +1,55 @@ +# Answering opencode permission prompts + +Verified 2026-09-23 with opencode 1.18.30 and Orca 1.4.209 (setup test, `reviewer-qwen`). + +## How a prompt shows up + +Orca does **not** flag it: `worker-list` / `worker-show` report +`attention.requiresAction: false` and `nextAction: none` while the worker waits. The signs are: + +- `orca orchestration worker-read --dispatch --json` → `stage.activity` is `"waiting"`. +- `orca terminal read --terminal --json` → `result.terminal.tail` contains the lines + + ``` + △ Permission required + # Shell command (or the tool name, e.g. an edit) + $ + Allow once Allow always Reject + ``` + +So on every wait timeout, read each active worker's terminal tail and search for +`Permission required`. The line after the `# ...` header is the request. + +## Keystrokes + +The prompt opens with **Allow once** selected. Right arrow moves the selection. + +| Decision | Command | +|---|---| +| Allow once | `orca terminal send --terminal --text "" --enter --json` | +| Reject | `orca terminal send --terminal --text $'\e[C\e[C' --json`, then `orca terminal send --terminal --text "" --enter --json` | + +**Never choose "Allow always".** It changes the session's rules for the rest of the task +and leaves no record. + +## After a reject: the worker stops + +Rejecting ends opencode's whole turn. The agent goes idle **without** sending +`worker_done`. Right after a reject, type a follow-up into its terminal: + +``` +orca terminal send --terminal --text "Orchestrator: your request to run '' was rejected because . . Then continue the task and send worker_done as instructed." --enter --wait-submit 10 --json +``` + +## What was checked and works without prompts + +- Writing a file under `orchestration/` with the edit tool (reviewer): allowed. +- Writing a file outside `orchestration/` (reviewer): refused with no prompt, and the agent + moved on. +- `git stash list` (denied pattern): refused with no prompt. +- `orca orchestration send ... worker_done` from the preamble: allowed, no prompt. + +## Not tried + +An opencode plugin that turns permission requests into Orca `ask` messages. The terminal +route above works, so it is not needed now. diff --git a/.agents/skills/orchestrate-plan/references/task-specs.md b/.agents/skills/orchestrate-plan/references/task-specs.md new file mode 100644 index 0000000..9b7515b --- /dev/null +++ b/.agents/skills/orchestrate-plan/references/task-specs.md @@ -0,0 +1,145 @@ +# Task specs and report format + +Fill these in for every Dispatch. Orca adds its own worker preamble (IDs, how to `ask`, +how to send `worker_done`) above your spec; do not repeat it. Replace every `<...>`. +Specs must stand alone: a worker has not seen this conversation, the other workers, or +earlier tasks. The role's standing instructions live in its role file (`.agents/roles/`); +if `ROSTER.md` says the runner does not load it, paste it above the spec. + +Orca's spec contract asks for Target, Change, Constraints, Ownership and Observable +acceptance. The templates below cover all five. + +--- + +## Coder: implement + +``` +ROLE: coder for plan task . + +TARGET: repository at , branch . Plan file: . + +READ FIRST, in this order: (whole file), then every file its session protocol +lists (). Follow that protocol and its "rules every task +follows" exactly, except where this spec overrides them. + +THE TASK, copied from the plan: + + +CHANGE: implement exactly this task. Nothing from other tasks, no drive-by fixes. + +CONSTRAINTS: +- Before editing any function or class, do the caller check the plan describes and + report what you found in your worker_done summary. +- Use only terms from the glossary. If you need a word it lacks, use Orca `ask` and wait. +- If the plan does not answer a question, use Orca `ask` and wait. Do not guess. +- If you cannot finish without going outside the task, send an Orca escalation. + +OWNERSHIP: you may edit files under src/ and test/, and . +Do not edit , orchestration/, or any other plan or doc file unless the task +says so. You are the only agent editing files. + +COMMIT: when the named tests and the full suite pass, make exactly ONE commit: + git add (never `git add -A` or `git add .`) + git commit -m ": " +Never add anything under orchestration/. Never push, amend, rebase, reset, stash, +checkout or switch branches. + +ACCEPTANCE: the task's named tests and the full suite pass (). In worker_done include: the commit hash, the test summary lines, the caller +check results, and anything you were unsure about. +``` + +## Coder: fix round + +``` +ROLE: coder for plan task , fix round of at most 5. + +You implemented this task earlier in this session. The reviewers found the problems +below. The orchestrator has already filtered them: fix every item. If you believe an +item is wrong, do not skip it silently: use Orca `ask` and explain why. + +FIX LIST (also in /round-/fix-list.md): + + +> + +Same constraints, ownership and commit rules as before. Make exactly ONE commit: + git commit -m ": fix from review round : " + +ACCEPTANCE: every item addressed, named tests and full suite pass. In worker_done list +each item number with what you did, plus the commit hash and test summary lines. +``` + +## Reviewer: first review + +``` +ROLE: () for plan task . You are one of six reviewers. You only read. + +TARGET: the commits `git log ..` on branch in . +Look at them with `git show` and `git diff ..`. Read surrounding code as needed. + +READ FIRST: (whole file) and every file its session protocol lists +(). The task, copied from the plan: + + +YOUR FOCUS: as your role file says (.agents/roles/.md). Stay in that lane. + +OWNERSHIP: you may create exactly one file: . Do not edit any other file. +Do not run git commands that change anything. You may run the test suite. + +OUTPUT: write the report in the format below to . In worker_done give the +verdict and the count of findings per severity, and pass --report-path . +``` + +## Reviewer: re-review + +``` +ROLE: same as before, plan task , re-review after fix round . + +The coder made a fix commit: . See it with `git show `. +Your previous report: . The fix list the coder worked from: +/round-/fix-list.md (some of your findings may have been dropped on purpose; +the reasons are in /decisions.md). + +Do three things: +1. For each of your previous findings: fixed / not fixed / dropped by orchestrator. +2. Did the fix commit break or weaken anything in your focus area? +3. New findings in your focus area caused by the fix commit. + +OWNERSHIP and OUTPUT: same as before, but write to . +``` + +--- + +## Report format (all reviewers) + +```markdown +# — — round + +Verdict: approve | changes-needed + +## Findings + +### F1 — must-fix | should-fix | nit +- Where: path/to/file.py:123 +- What: one sentence. +- Why: one or two sentences; quote the plan line if it is a plan rule. +- Suggested fix: one or two sentences. + +### F2 — ... + +## Previous findings (re-review only) +- F1: fixed | not fixed (why) | dropped by orchestrator + +## Notes +Anything else, e.g. tests run and their summary line. +``` + +Severity meaning: +- **must-fix**: wrong behaviour, a broken rule from the plan, a missing named test, a test + that cannot fail. +- **should-fix**: real weakness that is cheap to fix now (missing edge-case test, unclear + error message, duplicated logic). +- **nit**: preference only. The orchestrator will not send these to the coder. + +Verdict is `approve` only when there are no must-fix or should-fix findings. diff --git a/CONTEXT.md b/CONTEXT.md index 915941b..a39a08d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,15 +20,19 @@ A serializable description of an instrument, parameter, or method that lets clie _Avoid_: schema, spec **Broadcast**: -A parameter-change event published by the Server on its PUB socket for any subscriber. +A parameter-change event published by the Server on its PUB socket for any subscriber. Most Broadcasts are produced by the Server when it executes a client request; an instrument that implements the **Broadcaster** contract can also emit its own. _Avoid_: notification, event stream +**Broadcaster**: +The opt-in contract by which an instrument emits its own Broadcasts: it exposes `add_broadcast_sink` / `remove_broadcast_sink` / `broadcast`, and the Server registers itself as a sink when the instrument joins the Station. Instruments without it are untouched. The Parameter Manager is the first Broadcaster. It emits `pm-lock-update` (payload: a `PMLockBluePrint`) and `pm-type-update` (payload: a `PMTypeBluePrint`), and re-emits the Server's own `parameter-creation` / `parameter-deletion` for parameters it creates or removes as side effects of Type edits. +_Avoid_: hook, callback, event emitter + **Virtual Instrument**: An instrument that lives entirely in the Server with no hardware behind it. _Avoid_: soft instrument, fake instrument (that's a dummy instrument, for testing) **Parameter Manager**: -The flagship Virtual Instrument: a hierarchical, persistent, profile-aware store of experiment parameters. +The flagship Virtual Instrument: a hierarchical, persistent, profile-aware store of experiment parameters. Its root owns the Type registry and all Lock and Type methods; its submodules are **Parameter Groups**. _Avoid_: param store, PM **Client Station**: @@ -49,6 +53,47 @@ A hardware-free test instrument shipped in `instrumentserver.testing` for develo A Server acting as a Client of another Server, so instruments can be re-exported downstream. _Avoid_: server-in-server, daisy-chaining (fine in prose, not as the term) +**Parameter Group**: +A submodule inside the Parameter Manager: a plain container of parameters and nested groups with no file, profile, Type or Lock logic of its own. Every submodule is a Parameter Group; only the root is the Parameter Manager, which extends the group with those responsibilities. +_Avoid_: nested manager, sub-manager + +**Type**: +A named set of relative parameter paths (each with a default value and unit), plus **Nested Types** required at named submodules. Type entries carry no value kind and no description. A Type is structural: it describes a shape, not a list of members. +_Avoid_: template, schema, class. Not to be confused with a parameter's **value kind** (numeric, string, bool…), which is the `ParameterTypes` enum in code. + +**Nested Type**: +A Type required at a named submodule of another Type, e.g. `qubit` requires a `readout` at its submodule `readout` (`add_nested_type("qubit", "readout", "readout")`). The outer Type's effective parameter set is its own entries plus every Nested Type's set under that submodule name. Nesting can go several levels deep but may not cycle. +_Avoid_: include, import, inherit, extend, subtype + +**Instance**: +A Parameter Manager submodule (at any depth, never the root and never under Globals) that carries every parameter path of a Type, including those of its Nested Types, each with the unit the Type declares. Values are irrelevant to matching. Membership is duck-typed and recomputed on demand, never stored. An empty Type has no Instances. A submodule may be an Instance of several Types at once. +_Avoid_: member, tagged submodule + +**Claiming Type**: +The Type whose tint a parameter row shows when several Types cover it: the innermost (most deeply nested) Type wins, then the largest. + +**Lock**: +A rule attached to one parameter (the **Follower**) naming another parameter, its **Target**. While the Lock is **locked**, the Follower answers `get` with the Target's value and refuses `set`. While **unlocked**, the Follower behaves as a plain parameter but remembers its Target so it can be locked again. Removing the Lock forgets the Target. Locks chain (a Target may itself have a Lock) but never cycle. Values are pulled on `get`; nothing is ever pushed into a Follower. +_Avoid_: link, binding, mirror, source. Not the server's **instrument mutex**. + +**Target**: +The parameter a Lock points at. A parameter that is the Target of at least one locked Lock is marked as such in the tree. Deleting a Target removes the Locks that pointed at it; their Followers become plain parameters. +_Avoid_: source + +**Follower**: +A parameter that has a Lock. Convenience noun for prose; the code says "the parameter's lock". + +**Type Lock**: +A rule on a Type entry naming a Target. Declaring it puts an ordinary, locked Lock on that parameter in every current Instance, and every future Instance gets it at creation. Its default Target is `_globals..`, created on demand with the entry's default and unit. Instance parameters that already have a Lock on another Target are skipped with a warning. Removing the Type Lock removes only the rule; the Locks it created stay until removed individually. Instances that stop matching keep their Locks. +_Avoid_: group lock, rule (alone) + +**Globals**: +The reserved `_globals` submodule of the Parameter Manager that holds default Targets for Type Locks. It is never an Instance of anything. + +**Instrument mutex**: +The server's per-instrument `threading.RLock` that serialises concurrent `call`s to one instrument. Prose uses "instrument mutex" so it never collides with **Lock**; the code keeps its current names (`_instrument_locks`) with a rename note only. +_Avoid_: instrument lock (in prose) + ## Relationships - The **Server** owns instruments; **Clients** reach them only through **Proxy Instruments** built from **Blueprints** @@ -56,11 +101,13 @@ _Avoid_: server-in-server, daisy-chaining (fine in prose, not as the term) - A **Client Station** groups Proxy Instruments for one client; many Client Stations can share one Server - A **Virtual Instrument** is served like any other instrument; the **Parameter Manager** is one - **Chained Servers** compose: a downstream Server proxies an upstream Server's instruments +- A **Type** is a shape; an **Instance** is any submodule matching it. Adding a parameter to a Type writes it into every Instance; removing one from a Type leaves it on the Instances, which then show it as an untyped row +- A **Lock** makes a **Follower** read its **Target**'s value while locked; a **Type Lock** does this for every Instance of a Type, defaulting its Target to **Globals** ## Example dialogue > **Dev:** "If I set a value on a **Proxy Instrument**, who finds out?" -> **Domain expert:** "The **Server** executes the set under that instrument's lock, then emits a **Broadcast** — every subscribed GUI and **Listener** sees it, no polling needed." +> **Domain expert:** "The **Server** executes the set under that instrument's **instrument mutex**, then emits a **Broadcast** — every subscribed GUI and **Listener** sees it, no polling needed." > **Dev:** "And a **Client Station** is a second server?" > **Domain expert:** "No — it never owns instruments. It's a scoped view: one client's chosen set of **Proxy Instruments** over the same shared **Server**. If you actually need a second server re-exporting instruments, that's **Chained Servers**." @@ -69,3 +116,5 @@ _Avoid_: server-in-server, daisy-chaining (fine in prose, not as the term) - "apps" — the five console entry points are launchers, not five separate applications; they collapse into three features (Server, Client Station, Monitoring) plus two convenience launchers (Detached GUI, Parameter Manager GUI). Resolved: docs pages follow features, not entry points. - "sub-server" — used colloquially for **Client Station**; resolved: explanation, not terminology. - "virtual instrument" vs "dummy instrument" — distinct: virtual = production feature with no hardware; dummy = testing stand-in for hardware. +- "type" — overloaded: the `ParameterTypes` enum is a parameter's **value kind** (drives widget choice); the Parameter Manager's **Type** is a structural shape of a submodule. Resolved: **Type** is the structural concept; the enum is called "value kind" in prose. +- "lock" — overloaded: the server's per-instrument RLock vs the Parameter Manager's user-facing **Lock**. Resolved: the RLock is the **instrument mutex** in prose; code names unchanged. diff --git a/PLAN_docs_refactor.md b/PLAN_docs_refactor.md new file mode 100644 index 0000000..59e47da --- /dev/null +++ b/PLAN_docs_refactor.md @@ -0,0 +1,578 @@ +# Documentation Refactor — Master Plan + +This is the guiding document for the instrumentserver documentation refactor. It records the +goal, the philosophy, the agreed structure, and the phase-by-phase breakdown of the work. +It is a living document: statuses are updated as work progresses, and section lists are +refined during each page's grilling session. + +Companion files: + +- `CONTEXT.md` — the canonical glossary. All docs use its terms exactly. +- `TEST_AUDIT.md` — tracker for test and docstring gaps discovered while documenting. +- `test/docs_verification/` — verification scripts (one per page; see Workflow). + +## How to use this document (session protocol) + +Each work session starts fresh from this document. The intended unit per session is one +sub-sub-phase (a page section), or a few small ones. On session start: + +1. Read this file top to bottom, then `CONTEXT.md`, then `TEST_AUDIT.md`. +2. Find the next open item: the first non-`[x]` checkbox in phase order, unless Marcos + names a different target. +3. Start with GRILL, and GRILL means **interviewing Marcos**: ask questions one at a + time, with a recommendation per question, until the section's scope and claims are + agreed. Never skip this or self-answer it. +4. Work the loop (GRILL → VERIFY → DRAFT → REVISE → AUDIT). Update the checkbox status + marker in this file as the section moves through the loop. +5. Update `CONTEXT.md` immediately when a term is resolved, and `TEST_AUDIT.md` at every + AUDIT step. Leave all changes uncommitted (see Ground rule). + +Building the site locally: ALWAYS build from a clean slate, from the `docs/` folder: + +```bash +cd docs && uv run make clean && uv run make html +``` + +Never trust an incremental build when checking work: stale caches hide warnings and +keep deleted pages alive. Output lands in `docs/build/`. The quality bar is a green +clean build with **zero Sphinx warnings**. (CI runs the same html target; pandoc is +the only system dep.) + +--- + +## Goal + +Replace the current under-construction documentation site with a complete, **verified**, +well-organized site covering everything the package actually ships — modeled on the +structure and toolchain of the sibling labcore docs, published at +`toolsforexperiments.github.io/instrumentserver`. + +## Why + +- The current site documents a fraction of the package. Of the five console entry points, + only the server is described; the Client Station, the monitoring/deployment stack, the + Parameter Manager's profiles, polling, chained servers, and virtual instruments are + invisible. +- What *is* documented is unverified and partly wrong (the API page references classes + that don't exist; the overview tells readers to email the maintainer). +- The lab onboards through these docs. Every undocumented feature is re-explained by hand. +- Documenting forces verification: writing each page doubles as an audit of behavior and + of test coverage. + +## Philosophy + +1. **Nothing is documented without being executed.** Every behavioral claim on every page + is backed by a runnable verification script committed to the repo. No claims from + memory, no claims from reading code alone. +2. **Docs describe what ships, not what's planned.** No aspirational features. The + under-construction warning shrinks every phase and disappears at the end. +3. **Pages follow features, not entry points.** The five console scripts are launchers for + three features (Server, Client Station, Monitoring) plus two conveniences (Detached + GUI, Parameter Manager GUI). Each entry point is documented inside the feature it + serves. +4. **Two depths, cross-linked.** The User Guide answers "how do I use this?"; the + Technical Guide answers "how does this work inside?". Same feature, two pages, two + depths (e.g. external broadcast: mentioned in `server.md`, explained in + `broadcasts.md`). +5. **One language.** Terminology comes from `CONTEXT.md` (Virtual Instrument, Client + Station, Blueprint, Broadcast, Listener…). If a page needs a term the glossary lacks, + the glossary is updated first. +6. **Auto things stay auto.** API reference is pure autosummary — no hand-written + navigation lists that rot independently of the code. +7. **Documenting drives testing — and docstrings.** Each verified behavior is checked + against the pytest suite, and each referenced symbol's docstring is checked for + correctness; gaps go to `TEST_AUDIT.md` and become parallel work, never blocking + docs. By the end, everything documented is tested and everything referenced has an + accurate docstring. + +## Ground rule + +**NEVER COMMIT FOR ANY REASON.** All git commits (and pushes) are done by Marcos +personally. Agents and collaborators leave changes in the working tree, always. + +## Workflow (the unit of work is a page *section*) + +Every page is a **sub-phase**; every section of a page is a **sub-sub-phase**. Each +section goes through this loop: + +``` +1. GRILL — discuss scope/claims of this section; resolve terminology against CONTEXT.md +2. VERIFY — behavior exercised in the page's verification script (a new section of + test/docs_verification//verify_.py) — in files, never ad hoc. + We observe how it *really* works before writing a word about it. +3. DRAFT — first draft written from the verified behavior +4. REVISE — feedback loop on the draft until approved +5. AUDIT — two checks on what this section touched: + (a) tests — is the verified behavior covered by the pytest suite? + (b) docstrings — does every class/function/parameter the section + references have a correct, current docstring? + Gaps from either go in TEST_AUDIT.md (tests table / docstrings table) +``` + +Status legend used throughout this document: + +`[ ]` not started · `[G]` grilling · `[V]` verified · `[D]` drafted · `[R]` in revision · `[x]` approved + audited + +**Verification scripts**: one script per page, named `verify_.py`, with one clearly +marked section per page section. Runnable standalone (asserts documented behavior, +exits 0). Committed throughout the project; deleted in the final cleanup phase after the +test audit has been fully harvested. + +**Shared helpers** (`test/docs_verification/helpers.py`): common utilities in the spirit +of pytest fixtures, so scripts stay focused on the behavior they verify — a single +canonical way to start/stop a server (context manager, with Dummy Instruments and an +optional config file), get a connected client, and capture Broadcasts. Scripts never +hand-roll server startup. Helpers that prove broadly useful are candidates to graduate +into pytest fixtures during the audit harvest. + +> Section lists below are **provisional** — each page's grilling session may add, merge, +> or cut sections. The page list and phase order are settled. + +## Writing style and tone + +Rules for all prose on the docs site (drafts are checked against these in REVISE): + +- **NEVER use em-dashes (—) or en-dashes (–) as punctuation.** Restructure the sentence, + or use a comma, colon, parentheses, or a separate sentence instead. +- **Friendly and informal, but authoritative.** Write like a knowledgeable labmate + explaining the tool at the whiteboard: relaxed language, contractions are fine, the + occasional aside is fine. But when describing how things work or should be done, state + it plainly and confidently. No hedging ("should probably", "it seems") about behavior + we have verified, and no false uncertainty for politeness. +- **Address the reader as "you"**, describe the project's choices as "we". +- **Terminology comes from `CONTEXT.md`**, capitalized terms used consistently. +- Prefer short sentences and concrete examples over abstract description. Every feature + explanation should reach a runnable snippet or a screenshot quickly. + +**Screenshots.** Use them generously, especially anywhere UI behavior is being described. +While drafting, do not block on the image existing: insert a clearly marked placeholder +that spells out exactly what the screenshot must show, e.g. + +```markdown +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +Parameter Manager GUI with the profile dropdown open, two profiles visible, +one parameter starred. Annotate the star column. +::: +``` + +Placeholders are gathered per page once the page is finalized; Marcos captures the real +screenshots and replaces the placeholders. A page section can reach `[x]` with +placeholders still in, but Phase 6 closeout requires zero placeholders on the site. + +Screenshot conventions (decided during Phase 2): + +- Files live in `docs/_static/` in subfolders mirroring the docs tree: + `_static/
//_{light,dark}.png`. +- Every screenshot is captured twice, app in light and dark theme, and embedded as a + pair of `{image}` directives with the theme's `only-light` / `only-dark` classes, so + the site shows the variant matching the reader's theme toggle. +- Placeholder admonitions spell out both file paths and carry the ready-to-uncomment + image block next to them. +- Old screenshots inherited from the previous site stay in `_static/` root until their + page is reworked; each is checked against the current UI in its page's VERIFY step. + +--- + +## Site structure (settled) + +``` +docs/ +├── index.md — landing page +├── about.md +├── getting_started/ +│ ├── index.md +│ ├── installation.md — tabbed: uv (recommended) / pip / conda; PyPI note +│ ├── quickstart.md — server + dummy instrument + first client call +│ └── how_it_works.md — conceptual overview; home of the flow animation +├── user_guide/ +│ ├── index.md +│ ├── client.md — Python Client (first page; most-used interface) +│ ├── server.md +│ ├── gui_features.md +│ ├── parameter_manager.md +│ ├── client_station.md +│ ├── monitoring.md +│ ├── configuration.md +│ └── advanced/ +│ ├── subclient.md — developer guide to live Broadcast subscriptions +│ ├── virtual_instruments.md +│ └── chaining_servers.md +├── technical_guide/ +│ ├── index.md +│ ├── architecture.md +│ ├── blueprints_and_proxies.md +│ ├── broadcasts.md +│ └── custom_widgets.md +└── api/ + └── index.md — pure autosummary, all public modules +``` + +Removed: `examples/` (cut — no good use here), hand-written API quick-navigation. +Kept out of the toctree: `docs/agents/` (excluded via `exclude_patterns` so Sphinx +doesn't warn). + +Toolchain: `pydata-sphinx-theme` + MyST + autosummary + GitHub Actions → GitHub Pages, +with **`sphinx-design`** for tabs/cards and **`sphinx-copybutton`** for site-wide code +copy buttons. **`sphinx-prompt`** supplies visible, copy-safe shell prefixes, while +interactive Python examples use `pycon` prompts whose returned values are displayed but +excluded when copied. Look & feel otherwise unchanged. + +--- + +## Phase 0 — Foundations + +Small, mechanical, makes every later phase land cleanly. + +- [x] Add `sphinx-design` to the docs dependency group; enable in `conf.py` +- [x] Add `sphinx-copybutton` to the docs dependency group; enable copy buttons on all + code blocks, using the PyData-recommended `nbsphinx`-safe selector +- [x] Add `sphinx-prompt`; use prompt directives for terminal examples and `pycon` for + interactive Python so prefixes and returned values are visible but copied code + remains directly runnable +- [x] Add `agents/` to `exclude_patterns` in `conf.py` +- [x] Create the new directory skeleton with stub index pages (stubs marked clearly; + nothing half-written ever sits in the nav). Each stub page carries a small + summary of what that page will become — its scope and planned sections — so + the skeleton doubles as a public roadmap of the site +- [x] Rewrite `index.md` landing page: honest scope statement, link cards to the four + sections; remove the "email Marcos" warning from all pages +- [x] ~~Touch up `about.md`~~ Decided instead: `about.md` deleted; landing page links to + the Tools for Experiments organization page externally +- [x] Create `TEST_AUDIT.md` and `test/docs_verification/README.md` (conventions) +- [x] Write `test/docs_verification/helpers.py`: server start/stop context manager + (Dummy Instruments + optional config), client factory, Broadcast capture — + the single canonical startup path all verification scripts use. First survey + the existing pytest fixtures under `test/` and wrap/reuse them rather than + invent a parallel startup path — one canonical way, not two. + Decided in grilling: helpers provide BOTH `server()` (in-process, mirrors the + `start_server` pytest fixture) and `server_process()` (the real CLI, + headless, for sections about launch behavior); Broadcast capture wraps + `SubClient` (the intended live-update path); port 5555 default, + overridable, fail loudly if busy. All helpers smoke-tested green. +- [x] Consolidate `docs/agents/domain.md` vs `CONTEXT.md` — one glossary, not two. + Resolved on inspection: `domain.md` defines no terms; it instructs agent + skills to read and use `CONTEXT.md`. One glossary already +- [x] CI builds green with zero Sphinx warnings (local clean build at zero; CI runs the + same command and will confirm on push) +- [x] Old-page teardown rule in effect from here on: when a new page lands, the old + page it replaces is deleted in the same change (no two versions in the nav; + `overview.md`, old `configuration.md`, `instrumentmonitoring.md` all die this way) + +## Phase 1 — API Reference (easy wins; everything after can cross-link it) + +- Page: `api/index.md` + - [x] Extend autosummary to all public modules: add `params`, `gui`, `config`, + `apps`, `testing`, `serialize`, `base` to existing four. + Decided in grilling: also add `helpers` and `log` (13 modules total); + `resource.py` excluded (auto-generated Qt resources, not an API). One flat + autosummary block, modules enumerated explicitly (not a single recursive + root), short intro prose with pointers to User/Technical Guide + - [x] Delete the hand-written "Quick Navigation" (already references nonexistent + classes — `monitoring.monitor.ParameterListener` et al.) + - [x] Build and review the generated pages; fix import-time errors if any module + breaks autodoc. + Found: `testing/create_instrument.py` ran a module-level `InstrumentClient()` + on import, hanging the build; orphaned (zero dependents per GitNexus) and + buggy; deleted with Marcos's approval. Five malformed-RST docstrings fixed + (logged in TEST_AUDIT.md). Build green, zero warnings + - [x] Triage docstring quality: note worst offenders in the TEST_AUDIT.md docstrings + table up front; from then on the docstring audit runs continuously as part of + every section's AUDIT step (not a single pass). + Decided: module-docstring gaps (11 of 13 modules) logged only, not fixed now; + skim of public symbols logged 12 worst-offender groups + +## Phase 2 — Getting Started + +- Page: `installation.md` + - [x] Grill: supported Python versions, the `--no-deps` story, environment advice. + Decided: Python 3.11+ (same floor as latest qcodes 0.58.0; lab runs 3.11, CI + tests 3.13); clone + editable install as the canonical flow, uv recommended; + per-tool framing (uv: dependency of your measurement project, editable path or + git dep; conda: one env per measurement setup, pip -e inside it; pip+venv: + generic; the `--no-deps` aside was drafted but cut in REVISE as confusing); PyPI note as an + honest admonition at the top. Found+fixed during VERIFY: missing + `[tool.setuptools.package-data]` made wheels unimportable (logged in + TEST_AUDIT.md). All four install flows + monitoring extra smoke-tested green + - [x] Tabbed install: uv (recommended) / pip / conda; "PyPI upload in progress" note. + Approved after revision (cut lab/CI version aside, fixed clone contradiction, + added cd-before-clone guidance, cut `--no-deps`). AUDIT: wheel-import gap and + entry-point docstring gap already tracked in TEST_AUDIT.md +- Page: `quickstart.md` (verification script: `verify_quickstart.py`) + - [x] Start the server bare (no config file) + - [x] First client connection; create a Dummy Instrument via + `find_or_create_instrument` (grilled: `rf.Generator` as the example; + fixed missing `*IDN?` handling in all three rf dummies so creation + doesn't log a scary traceback; full pytest suite green after). + AUDIT: 4 test gaps logged (bare-server-empty, find_or_create + idempotency, rf.Generator initial values, all in TEST_AUDIT.md); + find_or_create_instrument docstring blemish logged + - [x] Get/set a parameter; observe the Broadcast (grilled: observed via the + GUI updating live, Broadcast name-dropped and linked, no SubClient code). + AUDIT: end-to-end Broadcast path (live server set → SubClient receive) + has no pytest coverage; logged as strongest integration-test candidate + - [x] Starting with a config file (the same setup, declared in YAML; grilled: + generator + Parameter Manager with its custom GUI; config committed as + quickstartConfig.yml next to the verification script). REVISED: cut the + Parameter Manager from the example (deferred to its own page); custom-GUI + capability now a note linking gui_features.md. AUDIT: server-from-config + end-to-end instrument creation untested (mocks only); logged + - [x] Where to go next (links into User Guide; nothing to verify, no audit) +- Page: `how_it_works.md` — conceptual overview; no page-specific verification script + by decision. Final shape: a brief statement of the shared-hardware problem, the + approved eleven-step scrollytelling diagram, and four short sections that supplement + the visual with conceptual context rather than transcribing it. Implementation details + are deferred to `technical_guide/architecture.md`. + - [x] **The Server owns the instrument:** explain the real QCoDeS driver's lifetime, + the Server's authoritative ownership, and configuration versus runtime creation. + - [x] **The Client builds a Proxy Instrument:** distinguish the Client, Blueprint, and + Proxy roles; explain that a Proxy provides the local interface but is not a copy + of the real instrument or its state. + - [x] **A call reaches the hardware:** explain the Proxy's request/result interaction, + Server-side execution, and fresh parameter reads without implementation machinery. + - [x] **A Broadcast reaches subscribers:** distinguish direct calls from observation by + the Server GUI and Listener; close with the Technical Guide pointer. AUDIT: + existing and newly identified test/docstring gaps recorded in `TEST_AUDIT.md`. + +## Phase 3 — User Guide core + +- Page: `client.md` (page title: **Python Client**; verification: `verify_client.py`) + - [x] **Connect and get an instrument** (verified, tested, built, visually checked, + and audited). Decided: make the + section a focused Python Client API walkthrough, not another system overview: + establish a Client session, inspect the Server's instruments, obtain one usable + Proxy Instrument, and disconnect. Keep system concepts brief and cross-link the + Quickstart and `how_it_works.md` for their fuller treatments. Renamed from the + vague `basic_usage.md` to `client.md`, with the page title **Python Client**. + To keep the walkthrough fully followable, create its example instrument inline + with `find_or_create_instrument`; do not require readers to arrive with a preloaded + Server just to avoid repeating the one-line creation from the Quickstart. + Start the Server with its GUI (`instrumentserver`) so readers can watch the + instrument appear. Teach a long-lived `Client` as the primary measurement + pattern, ending with `disconnect()`; show the context manager briefly as the + secondary pattern for short scripts. Use `rf.Generator` for this opening + walkthrough and continue using it for parameter examples. Keep and use the Proxy + returned by `find_or_create_instrument`; mention `get_instrument` only as the + alternative when the named instrument already exists, rather than constructing + a redundant second Proxy in the walkthrough. Spell out `host="localhost"` and + `port=5555` in the Client example, then note that both are defaults and link to + `server.md` for addresses and ports. Start with the explicit real CLI spelling + `instrumentserver -p 5555 -a 127.0.0.1`; note that both values are defaults and + that `-a` adds listening addresses while loopback is always included. Reuse the + Quickstart's existing `server_generator_{light,dark}.png` success-state images; + reference the shared assets directly rather than creating duplicate screenshots. + Explain that `find_or_create_instrument` intentionally mirrors QCoDeS' method of + the same name and link its API documentation. Its lookup is name-based: when the + name exists, it returns a Proxy for that instrument without validating the class + path supplied for creation. Add a note that constructing `Client` opens the ZMQ + connection but does not handshake with the Server; the first request confirms + communication. Keep timeout and reconnection details in their later section + - [x] **Use a Proxy Instrument** (verified, tested, built, and audited). Retain a compact nested-submodule + lesson because Blueprints reproduce the driver's hierarchy and users call nested + parameters and methods through the same Proxy interface. Use + `DummyInstrumentWithSubmodule` to verify access through `A.ch0` and + `A.dummy_function`. Document `update()` exactly: it fetches a fresh Blueprint, + synchronizes parameters and submodules, and adds newly reported methods, but does + not remove method objects already installed on the Proxy. Verify additions and + removals against a mutable test instrument and leave stale method removal as a + separate product improvement. Make the interaction self-contained by showing + callable parameter get/set and a + realistic method call: continue with `rf.Generator` for parameters, then create + `rf.ResonatorResponse` and call `modulate_frequency(delta=1e6)`. State that the + interface is native QCoDeS behavior: `ProxyParameter` is a QCoDeS `Parameter` + whose get/set commands call the Server. Show the familiar callable form, mention + explicit `.get()`/`.set()` without treating either syntax as instrumentserver-specific + and link the relevant QCoDeS instrument documentation, but do not teach validators, + snapshots, or general driver authoring here. Retain the custom-serialization + contract because values used by Proxy Parameters and methods must cross the wire. + The agreed example uses importable `SweepRequest` and `SweepResult` dataclasses + with a `ClassVar` named `attributes`, plus an Analyzer method that accepts the + request and returns the result. Verify built-in transport types, `FieldVector` + identity boundaries, the complete custom request/result flow, import and + constructor requirements, non-recursive custom-object and NumPy-field limits, + tuple/set conversion, top-level versus nested Enum behavior, and numeric-string + coercion. Audit every executable claim into permanent pytest coverage. + Completed with focused Client, serialization, Enum, and `FieldVector` + identity tests + - [x] **Animate the Proxy Instrument lifecycle.** Decided: add a conceptual + scrollytelling diagram in the style of `how_it_works.md`. Follow the full + lifecycle from the Server's Blueprint through local Proxy construction, then + follow a `FieldVector` parameter set and get across the process boundary. Show + serialization and deserialization explicitly, but keep wire fields and protocol + details for `technical_guide/blueprints_and_proxies.md`. Close with a brief note + that supported values work automatically and custom classes must follow the + requirements documented below the animation. VERIFY: the page script now checks + the Blueprint response codec and a live `FieldVector` Proxy Parameter round trip + with distinct Python objects in both processes. DRAFT: ten-step animation added + and clean Sphinx build passes with zero warnings. Preliminary AUDIT: lifecycle + behavior has pytest coverage; the pre-existing `FieldVectorIns` IDN traceback is + recorded in `TEST_AUDIT.md`. REVISION: moved the animation to the end of the + Proxy Instrument section so the concepts come first. GRILLED REVISION: the + eleven-step story now begins with `cli.get_instrument("magnet")`, distinguishes the + Client, Proxy, Server dispatcher, and real QCoDeS driver, then follows explicit + `FieldVector` creation, set, driver handoff, set response, get, and return events. The script or + notebook and InstrumentServer are the two outer regions. Serialization appears + only as transient messages crossing the ZMQ lane, not as persistent serializer + objects. Structural objects remain visible after introduction at 55% opacity + when inactive; only messages disappear. Each relationship has one bidirectional + route whose active arrow and motion show the current direction. The read action + hides the earlier target card and leaves the Server-owned current value and newly + reconstructed returned value visible; its closing text still identifies the + original target as a third distinct object. The real driver starts with a visible + zero-valued `FieldVector`; the reconstructed set value moves over and replaces it + in the driver during Step 7. The continuous diagram is split + into three labelled actions: create the Proxy Instrument, set the value, and read + the value. The clean build succeeded. Marcos accepted the animation at page + closeout and waived the unavailable in-browser review + - [x] **Save and restore parameter values** (verified, tested, built, and audited; VERIFY found tracked bug + [#152](https://github.com/toolsforexperiments/instrumentserver/issues/152)). + Decided: do not invent a + "batch API" concept. Organize the existing Client methods around one experiment + state workflow: collect values with `getParamDict`, apply a parameter dictionary + with `setParameters`, save selected instruments to JSON with `paramsToFile`, then + change and restore them with `paramsFromFile`. VERIFY against a real Server + established that `getParamDict` returns flat dotted paths and `setParameters` + accepts that flat form, while `paramsToFile` writes nested JSON and + `paramsFromFile` flattens it internally. Show both shapes; warn that passing the + nested file object directly to `setParameters` is silently ignored. Make clear + that these Client methods work with any existing Server-owned + instrument and read/write files on the Client's filesystem. Contrast them with + `ParameterManager.toFile`/`fromFile`, which execute on the Server, manage profile + files, preserve units, and can create or remove hierarchical parameters. Found a + blocking correctness bug: native JSON booleans become `0.0`/`1.0` in + `deserialize_obj`, so Boolean values fail validation and are not restored by + `setParameters` or `paramsFromFile`. Do not claim complete type round-tripping + until that tracked-code bug is fixed and the verification is rerun + - [x] **Handle errors and timeouts** (verified, tested, built, and audited). Decided: + do not describe the + behavior as automatic reconnection. A timed-out request raises by default and + is not retried; the Client replaces its ZMQ socket so a later request can work + after the Server becomes available. `disconnect()` permanently closes that + Client instance. Mention `raise_exceptions=False` as the logging/`None` behavior, + not as the primary pattern. Add a prominent warning that timeout means "no reply + before the deadline," not "the Server did not execute": the worker continues, + so check instrument state before retrying a non-idempotent hardware operation. + Include two runnable examples: an out-of-range RF Generator set showing how a + Server-side validation error reaches the Client, and a deliberate call to + `DummyInstrumentTimeout` showing the timeout followed by a successful later call. + VERIFY confirmed the validation failure arrives as a generic `Exception`; + a 0.2-second timeout raised at about 0.201 seconds, the Server method continued + exactly once, and the same Client's later request succeeded on its replacement + socket. Fixed during DRAFT: `DummyInstrumentTimeout` now answers `*IDN?`, with a + regression test, so the documentation example starts cleanly. Present + `raise_exceptions=False` only in a cautionary note for long-running UI + infrastructure with its own error reporting; normal measurement code keeps the + default because logged failures generally return ambiguous `None` +- Page: `server.md` (verify_server.py) + - [ ] Starting: GUI / headless / CLI flags / addresses & ports + - [ ] Detached GUI: what it's for (UI faults can't kill the Server) + - [ ] Instruments from config vs **programmatic creation** (core feature, up front) + and init scripts + - [ ] External broadcast: mention + what it enables (live UIs); link to broadcasts.md +- Page: `gui_features.md` (verify where scriptable; GUI behavior verified manually) + - [ ] Shared concepts: star / trash / hide / filter patterns + - [ ] Keyboard shortcuts: defaults, customizing via config (post-#138 behavior) + - [ ] Detachable tabs + - [ ] Custom instrument widgets: mention + config hook; link to custom_widgets.md +- Page: `parameter_manager.md` (verify_parameter_manager.py) + - [ ] Concept: the flagship Virtual Instrument; single source of truth + - [ ] Hierarchical parameters: add / remove / nesting + - [ ] Persistence: JSON files; profiles (refresh / switch) + - [ ] The Parameter Manager GUI + `instrumentserver-param-manager` launcher + - [ ] Using it from measurement code + +## Phase 4 — User Guide completion + +- Page: `client_station.md` (verify_client_station.py) + - [ ] Concept: scoped views of one shared Server (the "sub-server" idea, properly named) + - [ ] YAML config; parameter save/load paths + - [ ] The Client Station GUI + `instrumentserver-client-station` launcher + - [ ] Auto-reconnect behavior +- Page: `monitoring.md` (verify_monitoring.py) + - [ ] Concept: Broadcasts → Listener → sink → dashboard + - [ ] Polling: making the Server emit without client activity (`pollingRate`) + - [ ] The Listener app + listenerConfig; CSV sink; InfluxDB sink + - [ ] Writing a custom Listener + - [ ] The deployment stack: Docker compose, Grafana + InfluxDB, provisioning, + dashboards, alerting (absorbs existing instrumentmonitoring.md after + re-verification) +- Page: `configuration.md` (verify_configuration.py) + - [ ] File anatomy: every top-level section + - [ ] Instruments section; gui kwargs; glob patterns + - [ ] gui_defaults merge order (`__default__` → class → instance) + - [ ] shortcuts / pollingRate / networking sections +- Page: `advanced/subclient.md` (verify_subclient.py) + - [ ] Concept and audience: an instrumentserver developer building a live UI or + another Broadcast-driven component, not ordinary request/reply Client usage + - [ ] Correct Qt worker-thread lifecycle: construct, move, connect signals, start, + stop, and clean up without blocking the caller + - [ ] Subscribe to all instruments or filter by instrument name; handle the + `ParameterBroadcastBluePrint` emitted by `update` + - [ ] Link to the Technical Guide for PUB/SUB mechanics and message internals +- Page: `advanced/virtual_instruments.md` (verify_virtual_instruments.py) + - [ ] Concept (vs Dummy Instruments — see CONTEXT.md) + - [ ] Writing your own Virtual Instrument +- Page: `advanced/chaining_servers.md` (verify_chaining_servers.py) + - [ ] Concept: a Server as a Client of another Server + - [ ] Working setup walkthrough (promote the `instruments_all_the_way_down` + prototype into a verified, documented configuration) + - [ ] Limits and gotchas + +## Phase 5 — Technical Guide + +- Page: `architecture.md` — `how_it_works.md`'s deeper twin (verification script: + `verify_architecture.py`) + - [ ] ROUTER/DEALER request path; thread pool; per-instrument locks + - [ ] PUB/SUB broadcast path; ports (request port, port+1) + - [ ] Request lifecycle walkthrough (set-parameter end to end) + - [ ] Process layout: server / detached GUI / clients / listeners +- Page: `blueprints_and_proxies.md` (verify_blueprints_and_proxies.py) — the round trip + as one story + - [ ] Server side: introspection → Blueprint + - [ ] The wire: serialization of blueprints and values + - [ ] Client side: Blueprint → dynamic proxy (methods, signatures, submodules) + - [ ] Blueprint caching and invalidation +- Page: `broadcasts.md` (verify_broadcasts.py) + - [ ] What triggers a Broadcast; message format + - [ ] SubClient mechanics; how GUIs stay live + - [ ] External broadcast forwarding (deep dive promised by server.md) +- Page: `custom_widgets.md` + - [ ] How `gui.type` resolves to a widget class; the widget contract + - [ ] Writing and registering your own (worked example) +- [ ] Remaining flow animations (per prototype learnings from Phase 2) + +## Phase 6 — Closeout + +- [ ] Remove the under-construction warning entirely +- [ ] All 📸 screenshot placeholders replaced with real images (zero remain on the site) +- [ ] Full-site read-through: terminology pass against `CONTEXT.md`; link check +- [ ] Harvest `TEST_AUDIT.md`: every entry either has a test, a tracked issue, or a + written reason it needs neither +- [ ] Delete `test/docs_verification/` (end-of-process cleanup, as agreed) +- [ ] Drop unused docs deps if confirmed unused (e.g. notebook extensions, post-Examples cut) +- [ ] Rewrite `README.md`: short project pitch, install summary, prominent link to the + docs site (currently it only says "use a developer pip install") +- [ ] Coordinate with labcore: its `user_guide/instruments/instrumentserver.md` and + `instrumentmonitoring.md` duplicate content (and screenshots) now owned by this + site; shrink them to links so they don't become the new stale copy + +--- + +## Out of scope (deliberately) + +- **PyPI publishing** — wanted, but a separate later effort; installation.md carries an + "in progress" note. +- **Examples section** — cut; this project doesn't use examples well. +- **URL redirects** from the old structure — site is young; clean break. +- **Look & feel changes** — theme, logo, nav style all stay. +- **`docs/agents/`** — untouched except glossary consolidation (Phase 0). + +## Standing risks / notes + +- GUI-heavy sections (gui_features, the GUI parts of each app page) can't be fully + script-verified; those get manual verification noted in the verification script as + comments, and screenshots regenerated so images match current UI. +- Screenshots in `docs/_static/` are inherited from the old site — every reused image + must be checked against the current UI in its page's VERIFY step. +- Animation approach is unproven until the Phase 2 prototype; don't reference animations + from other pages before it lands. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md new file mode 100644 index 0000000..7d24194 --- /dev/null +++ b/PLAN_parameter_manager_redesign.md @@ -0,0 +1,619 @@ +# Parameter Manager Redesign — Master Plan (Types and Locks) + +This is the guiding document for adding **Types** and **Locks** to the Parameter Manager and +rebuilding its GUI around them. It records the goal, every decision taken in the design +interview of 2026-09-16, the way of working, and a phase-by-phase task list sized so that +one task fits one agent session. It is a living document: statuses are updated as work +progresses. + +Companion files (read all of them before starting any task): + +- `CONTEXT.md` — the canonical glossary. Use its terms exactly (Type, Instance, Nested Type, + Lock, Target, Follower, Type Lock, Globals, Parameter Group, Broadcaster, instrument mutex). +- `docs/adr/0001-duck-typed-parameter-manager-types.md` +- `docs/adr/0002-pull-based-locks.md` +- `docs/adr/0003-broadcaster-contract.md` +- The Claude Design mock: `https://claude.ai/design/p/1603bddf-2dee-49a1-bf02-14c4e1c430d7` + (exported copy: `/Users/marcosf2/Downloads/Parameter Manager Redesign Features/`; + the file `ParameterManager.dc.html` holds the markup and, at its end, the full logic). + The mock is a **web** prototype; the product is **PyQt**. Section "Design reference" below + translates it. + +--- + +## How to use this document (session protocol) + +Each work session starts fresh from this document. + +1. Read this file top to bottom, then `CONTEXT.md`, then the three ADRs. +2. Find the next open task: the first non-`[x]` checkbox in phase order, unless Marcos names + a different target. Do **one task** per session unless it is trivially small. +3. Before editing any function or class, run a **grep-based caller check**: search the + whole `src/` and `test/` tree for the symbol name and read every call site. Report what you + found in your first message. Do **not** use GitNexus (MCP or CLI): it is broken in this + project. The GitNexus rules in `CLAUDE.md` are suspended for this plan. +4. Implement with tests. Every task below names the test file(s) it must leave green. Run + `uv run pytest test/pytest/` for the named files, then `uv run pytest` for the whole + suite, before declaring a task done. Paste the summary line of the run. +5. Mark the checkbox `[~]` when you start and `[x]` when tests pass and you have reported. + If you stop mid-task, leave `[~]` and write a short "handover" note under the task. +6. **Commit atomic units of work; never push.** All commits go on branch + `marcosfrenkel/new-param-manager`. The coder commits code and tests, and only when the + task's named tests pass. The first implementation of a task is one commit; each round + of review fixes is its own commit. The orchestrator commits only review reports and + decision logs under `orchestration//` and this file's checkboxes; reviewers never + commit. + Every commit message starts with the task number (`0.1: split ParameterGroup out of + ParameterManager`). Never amend, squash, rebase or push: Marcos reads the history + commit by commit afterwards. +7. Update `CONTEXT.md` the moment a term is added or changed. Update this file when a + decision has to be revisited (add a dated note under "Decision record", never silently + change a decision). + +Status markers: `[ ]` not started · `[~]` in progress · `[x]` done and reported. + +--- + +## Goal + +Give the Parameter Manager two new features, controllable from Python clients and from +its Qt GUI alike, with GUIs updating live when another client changes something: + +- **Types**: a Type is a named shape (relative parameter paths with defaults and units, plus + Nested Types). Any submodule carrying that shape is an Instance. Adding an entry to a Type + writes it into every Instance; the GUI tints rows by their claiming Type. +- **Locks**: a parameter can have a Lock naming another parameter as its Target. While locked + it reads the Target's value and refuses writes. A Type Lock declares this on a Type entry so + every Instance follows one Target, by default a parameter under `_globals`. + +## Why + +- Multi-qubit devices repeat the same structure per qubit; today each copy is edited by hand + and drifts. Types make the structure explicit and keep copies complete. +- Shared settings (an LO frequency, a readout bandwidth) are duplicated across qubits; Locks + make one parameter authoritative without copying values around. +- Everything must work from measurement code, not only from the GUI, so the features are + instrument methods first and widgets second. + +## Non-goals (out of scope for this plan) + +- Value kinds / validators over the wire (`ParameterTypes`, the disabled type combo in + `AddParameterWidget`, `vals` in blueprints). Left exactly as is. +- Locks whose Target lives outside the same Parameter Manager (other instruments). +- The design's "offer bar" (lock the others too / make it a type rule) and Ctrl/Shift + multi-select batch locking. Single-parameter lock flow only. +- Dark theme. +- Renaming or changing the server's `_instrument_locks` (the instrument mutex). Add a + comment only. +- `TODO_type_cleanup.md` items and the dead `_refreshProxySubmodules` in `client/proxy.py`. + +--- + +## Way of working — rules every task follows + +1. **Atomic commits, no pushes.** See session protocol step 6. Reviewers never edit code + or commit; they write only their own report file. +2. **Glossary terms only**, in code comments, docstrings, test names, log messages and GUI + strings. If you need a word the glossary lacks, stop and ask. +3. **Validate, then mutate.** Every Parameter Manager method that changes state checks all + its preconditions first and raises before touching anything. No partial state on error. + Error messages name the offending paths (all of them, not the first). +4. **Names are strings.** Client-facing methods take and return dotted paths relative to the + Parameter Manager (`"q01.readout.IF"`), never parameter objects. Paths stored in files use + the full form with the instrument name, as the existing files do + (`"parameter_manager.q01.readout.IF"`). +5. **Tests per layer** (see "Testing"): unit tests without a server for all logic; proxy + tests for every client-facing method and broadcast; pytest-qt tests for GUI behaviour. +6. **Do not widen scope.** Pre-existing defects not listed in Phase 0 are noted in + `TEST_AUDIT.md`, not fixed. +7. **Do not break the existing API.** `add_parameter`, `remove_parameter`, `list`, `get`, + `set`, `has_param`, `parameter`, `toFile`, `fromFile`, `switch_to_profile`, + `refresh_profiles`, `list_profiles`, `to_tree`, `remove_all_parameters`, + `remove_empty_submodules` keep their signatures and behaviour (except where a decision + below explicitly changes them). +8. **Casing**: the code base mixes `snake_case` (`add_parameter`) and `camelCase` + (`toFile`). New methods are `snake_case`. New classes are `CamelCase` with the existing + `BluePrint` spelling for blueprint dataclasses. +9. **Docs pages** (Phase 6) follow the docs protocol in `PLAN_docs_refactor.md`: nothing + is documented without a verification script under `test/docs_verification/`, and the + site must build clean (`cd docs && uv run make clean && uv run make html`, zero warnings). + +--- + +## Current architecture — facts an agent must know (verified 2026-09-16) + +Package lives under `src/instrumentserver/`. + +**Parameter Manager** (`params.py`): `ParameterManager(InstrumentBase)`. `add_parameter` +forces `parameter_class=Parameter`, `set_cmd=None`, default `vals=Anything()`, and creates +missing submodules via `_get_parent(..., create_parent=True)`, which today instantiates a +**full `ParameterManager`** per submodule (`params.py:189`). Each such constructor calls +`refresh_profiles()` (lists the working directory) and `fromFile()` (tries to load +`parameter_manager-.json`). Persistence: `toFile` → `toParamDict` → +`serialize.toParamDict([self], includeMeta=["unit"])`, which reads the **qcodes snapshot with +`update=False`** (cache, never `get`). `fromFile` → `fromParamDict`. Profile files are +`parameter_manager-.json`, a flat map `{".": {"unit", "value"}}`. +`switch_to_profile` saves the current profile, `remove_all_parameters()`, loads the new one. + +**Server** (`server/core.py`): `_callObject` (:469) resolves the dotted target with +`nestedAttributeFromString(self.station, target)`, takes the per-instrument `RLock` +(`_get_lock_for_target`, :616), calls the object, and **broadcasts only**: `parameter-update` +(set), `parameter-call` (get), and via `_newOrDeleteParameterDetection` (:593) +`parameter-creation` / `parameter-deletion` when the called method is literally named +`add_parameter` / `remove_parameter` (it reads `kwargs["initial_value"]` and +`kwargs["unit"]` unconditionally: a latent `KeyError`). Any other method call broadcasts +nothing. `_broadcastParameterChange` (:570) calls `sendBroadcast(socket, topic=instrument +name, blueprint)`, on the worker thread. Instruments enter the Station in two places: +`_createInstrument` (:461, `self.station.add_component`) and at startup from the station +config (`self.station.load_instrument`, :146). + +**Blueprints** (`blueprints.py`): `ParameterBroadcastBluePrint(name, action, value, unit)` +(:356). `bluePrintToDict` (:415) serialises dataclasses field by field; `deserialize_obj` +(:929) rebuilds any dict with a `_class_type` key by evaluating that class name **in the +`blueprints` module namespace**, so new blueprint dataclasses must be defined there and carry +`_class_type`. `ParameterBluePrint` has no `vals`. + +**Client / proxy** (`client/proxy.py`): `ProxyInstrumentModule.update()` (:256) is the only +structural refresh; `Client.getBluePrint` caches (:539) and only `update()` invalidates. +Methods of the instrument are proxied generically from `MethodBluePrint`, so **any new public +method on `ParameterManager` is callable from clients with no client changes**, and its +return value must be JSON-serialisable by `ServerResponse` (dataclass blueprints are). +`SubClient` (:669) is the ZMQ SUB listener emitting `update(ParameterBroadcastBluePrint)`. + +**GUI** (`gui/instruments.py`, `gui/base_instrument.py`, `gui/parameters.py`): +`ParameterManagerGui(InstrumentParameters)` (:747) = `ProfilesManager` combo + +`ParameterManagerTreeView` (`QTreeView`, delegate column 2 holds a `ParameterWidget` with a +red delete button per row) + `AddParameterWidget` strip. Model `ModelParameters` +(`QStandardItemModel`, columns `[name, unit, delegate]`) owns a `SubClient` on a `QThread` +and handles broadcasts in `updateParameter` (:441): creation → `instrument.update()` + +`addItem`; deletion → `removeItem`; update/call → `itemNewValue` signal. +`ParameterManagerTreeView.onItemNewValue` (:705) calls `widget.paramWidget.setValue`, which +only `AnyInput`/`NumberInput` implement (the generic tree uses `widget._setMethod`). +Launcher `apps.py:parameterManagerScript` (:123) builds `Client(port)` and +`ParameterManagerGui(pm)` **without** `sub_port`, so the GUI listens on `localhost:5556` +regardless of `--port`. + +**Tests** (`test/pytest/`): `conftest.py` starts one server per module on port 5555 +(`start_server`), `cli`, `param_manager` fixtures. `test_param_manager.py` is mostly local +(no server) and asserts the flat file shape (`data["params.my_param"]["value"]`). +`test_gui_navigation.py` is the pytest-qt pattern (`qtbot`, server on port 5599). +`pyproject.toml` has `pytest-qt`, `qt_api = "pyqt5"`. + +--- + +## Decision record + +Numbered as taken in the interview. Each is final unless a dated note below it says otherwise. + +**D1 — Types are duck-typed.** A submodule is an Instance because it carries the shape. +Nothing stores membership. (ADR-0001.) + +**D2 — Vocabulary.** "Type" is the structural concept; the `ParameterTypes` enum is a +"value kind" in prose. "Lock" is the user-facing concept; the server RLock is the +"instrument mutex" in prose, code unchanged. Full definitions in `CONTEXT.md`. + +**D3 — Locks pull on `get`.** A locked Follower asks its Target. `set` on a locked Follower +**raises** `ValueError` naming the Target. The Target knows nothing. Unlocking exposes the +Follower's own underlying value (no copying on unlock). Followers are computed by scanning. +(ADR-0002.) + +**D4 — Broadcaster contract.** A `Broadcaster` mixin (`add_broadcast_sink`, +`remove_broadcast_sink`, `broadcast`); the server registers itself as a sink when an +instrument with the mixin joins the Station. (ADR-0003.) + +**D5 — Three Lock states**: no Lock; Lock present and **unlocked** (Target remembered); Lock +present and **locked**. + +**D6 — `set` on a locked Follower raises.** No redirect, no silent ignore. + +**D7 — Chains allowed, cycles refused.** Each hop reads according to its own state (if `q02.x` +is unlocked, `q03.x` locked to it reads `q02.x`'s own value). Cycle detection walks Targets +regardless of locked/unlocked state. No self-lock. + +**D8 — Targets only inside the same Parameter Manager.** + +**D9 — Lock API**: `lock(name, target)` (creates or re-targets, sets locked), +`unlock(name)`, `relock(name)`, `toggle_lock(name)`, `remove_lock(name)`, +`get_lock(name) -> PMLockBluePrint | None`, `list_locks() -> dict[str, PMLockBluePrint]`, +`followers_of(name) -> list[str]`. + +**D10 — `pm-lock-update` broadcast**, one per affected Follower, `name` = full Follower path, +`value` = `PMLockBluePrint(target, locked)` or `None` when removed. Emitted by every Lock +method and by `remove_parameter` when deleting a Target drops Locks. Action strings stay +strings; module-level constants for all actions. + +**D11 — Type definition** = name; entries (relative path, default value, unit); Nested Types +(`add_nested_type(type, submodule, nested_type)`). No value kind, no description. + +**D12 — Instance matching**: every submodule at any depth, never the root, never under +`_globals`; a match requires every effective path to exist **with the declared unit**; values +irrelevant; an empty Type has no Instances; computed on demand, no cache. + +**D13 — Type edits and Instances**: add entry → create in every Instance lacking it with +default and unit; remove entry → nothing; change default → nothing; change unit → +**propagate** to every Instance (API only; no GUI unit editor); add Nested Type → like adding +its entries under the submodule; remove Nested Type → nothing; delete Type → nothing on +parameters. + +**D14 — `add_instance(type, name)`**: creates missing effective entries with defaults; keeps +existing ones; dotted names allowed (nested Instances); refuses `_globals`; a unit conflict on +any existing parameter raises **before anything is created**. + +**D15 — One Type registry on the root.** Submodules become `ParameterGroup` (plain +containers: parameters, nested groups, tree helpers; no files, profiles, Types, Locks). +`ParameterManager` extends `ParameterGroup` with those responsibilities. Every submodule is +a `ParameterGroup`. + +**D16 — Type API**: `add_type(name)`, `remove_type(name)` (raises if nested in another +Type), `add_type_parameter(type, path, default=None, unit="")`, +`remove_type_parameter(type, path)`, `set_type_parameter_default(type, path, value)`, +`set_type_parameter_unit(type, path, unit)`, `add_nested_type(type, submodule, nested_type)`, +`remove_nested_type(type, submodule)`, `list_types()`, `get_type(name) -> PMTypeBluePrint`, +`instances_of(type)`, `types_of(path)` (innermost first, then largest), `add_instance`. + +**D17 — Type Lock**: `lock_type_parameter(type, path, target=None)` stores the Target on the +entry, creates `_globals..` (entry default and unit) when no target is given, +and puts an ordinary locked Lock on that parameter in every current Instance; new Instances +(via `add_instance` or an `add_type_parameter` that completes them) get it at creation. +Instance parameters that **already have a Lock on another Target are skipped**; the method +returns the skipped paths and logs a warning. `unlock_type_parameter(type, path)` removes +**only the rule**; the Locks it created stay (the Locks panel removes them individually). +Individual Followers may be unlocked/relocked freely. Instances that stop matching keep their +Locks. Declaring again re-applies to everyone ("lock all"). + +**D18 — `_globals`**: created on demand; never an Instance; excluded from matching; +`add_parameter`/`add_instance` under it raise; its parameters are otherwise ordinary (set, +read, saved, may themselves have a Lock); `remove_parameter` on one is allowed and removes +the Locks and the Type Lock rule pointing at it; `_globals` is not a valid Type name. +GUI (Phase 5): deleting any Target asks for confirmation listing the Locks that will vanish. + +**D19 — File format version 2**, one file per profile: +```json +{ + "version": 2, + "parameters": { + "parameter_manager.q01.IF": {"unit": "Hz", "value": 101735237.0, + "lock": {"target": "parameter_manager.q01Data.IF", "locked": true}} + }, + "types": { + "qubit": { + "parameters": {"IF": {"default": null, "unit": "Hz", "target": null}, + "octave_gain": {"default": 10, "unit": "dB", "target": "parameter_manager._globals.qubit.octave_gain"}}, + "nested": {"readout": "readout"} + } + } +} +``` +Stored `value` is the parameter's **own** value. `lock` is present only on Followers. A file +without a top-level `version` key is the legacy flat map and loads as parameters only. +Saving always writes version 2. + +**D20 — Load order and validation**: the whole file is validated first (every Lock Target +and every Type Lock Target must exist in the file; otherwise raise **listing all missing +Targets**, leaving current state untouched). Then parameters (with existing `deleteMissing` +semantics), then Type definitions **without Instance side effects**, then Locks. +`switch_to_profile` saves the current profile, then clears parameters, Types and Locks, then +loads. + +**D21 — GUI scope**: extend the existing `ParameterManagerGui`; ship tab bar, tints and +gutter bands, lock column and per-row toggle, context menu Lock to…/Unlock, arm strip, Locks +panel, delete-Target confirmation, Types tab, Follower repaint on Target update. Defer offer +bar and multi-select. No dark theme. + +**D22 — Live structural updates**: the Parameter Manager re-emits `parameter-creation` / +`parameter-deletion` for parameters it creates/removes as side effects, and emits +`pm-type-update` (`name` = `.`, `value` = `PMTypeBluePrint` or `None`) +from every Type-editing method, including `lock_type_parameter`/`unlock_type_parameter`. +GUIs replace that one Type locally and recompute tints; no follow-up fetch. + +**D23 — Testing layers and files**: see "Testing". + +**D24 — Pre-existing fixes in scope**: `.get()` in `_newOrDeleteParameterDetection`; +`sub_port = port + 1` in `apps.py`; align `ParameterManagerTreeView.onItemNewValue` with the +generic `_setMethod` path. Everything else listed under Non-goals. + +**D25 — Docs in scope**: this plan writes the User Guide Parameter Manager page and the +Technical Guide Broadcasts page (with the Broadcaster contract) as Phase 6, then updates +`PLAN_docs_refactor.md`. + +**D26 — Names**: actions `pm-lock-update`, `pm-type-update`; classes `PMLockBluePrint`, +`PMTypeBluePrint`; parameter class `ManagedParameter`; container class `ParameterGroup`; +mixin `Broadcaster`. + +--- + +## Design reference — translating the mock to Qt + +The mock's state, and what it becomes: + +| Mock | Ours | +|---|---| +| `params[path] = {unit, value, lockedTo, prevLock}` | a `ManagedParameter`: own value in the cache, `lock` = (target, locked) or `None`. `prevLock` ≡ Lock present but unlocked. | +| `templates[type] = {params:[{name, unit, value, lockedTo}], includes:[{type, at}]}` | `PMTypeBluePrint`: `name`, `parameters: {path: {default, unit, target}}`, `nested: {submodule: type}`, plus the computed `effective: {path: {unit, from_type}}`. | +| `instancesOf(type)` | `instances_of(type)` (server side; the GUI calls it or recomputes from `PMTypeBluePrint` + the tree). | +| `claims()` (innermost type wins, then size; stack for gutter bands) | `types_of(path)` server side; the GUI ports `claims()` to compute tint + up to 3 gutter bands per row. | +| `lockOk(follower, source)` | cycle/self check inside `lock()`; walks Targets regardless of state. | +| `root(path)` / `valueOf(path)` | `ManagedParameter.get()` chain walk, hop by hop. | +| `toggleLock` | `toggle_lock`; with no Lock present the GUI arms the picker. | +| `deleteLock` | `remove_lock`. | +| `lockTypeParam` / `unlockTypeParam` / `armTypeLock` | `lock_type_parameter(type, path[, target])` / `unlock_type_parameter` / GUI arm then `lock_type_parameter(..., target=picked)`. | +| `offerFor` / `lockOthers` | deferred. | +| `_globals..` created on demand | identical. | +| `doAddInstance` (creates params, auto-locks per type rule) | `add_instance`. | +| `TINTS` (5 light tints + bar colours) | a fixed palette of 5 `QColor` pairs assigned in Type creation order; recycle when exhausted. Row background via the model's `BackgroundRole`; gutter band drawn by a small item delegate on column 0 or a dedicated 10 px column. | +| Lock column text: `locked to ` / `target ×N` | a new model column between unit and the delegate column. | +| Arm strip (label + completer + Cancel) | a `QWidget` row shown under the toolbar while arming: `QLabel`, `QLineEdit` with a `QCompleter` over all paths ranked like the mock (same relative path first), Cancel. Clicking a tree row while armed picks it. | +| Locks panel | a second `QTreeView` in a `QSplitter`, toggled by a toolbar action. Rows: Targets at depth 0, Followers beneath; Type Lock group rows first, labelled `[type: qubit] `. Per-row: lock/relock toggle, remove; value editor on Targets. | +| Types tab | a `QTabWidget` around the existing widget: tab 0 = existing Parameters view, tab 1 = Types view with three panes (type list + New type; entries + Add to type + nested type strip; instances + New instance). | + +Mock strings worth keeping verbatim (they follow the design system's voice): tooltips +"locked to X — unlock and go back to its own value", "unlocked — lock to X again", +"lock to… — then pick a source" (say **target**), "remove from the type only — instances keep +the parameter and lose the type fill", "Instances are derived: any submodule carrying the +whole set…". Replace every "source" with "target" and every "include" with "nested type". + +Icons already in `resource/icons/`: `lock.svg`, `unlock.svg`, `set.svg`, `delete.svg`, +`plus-square.svg`. Check `resource.qrc` lists `lock`/`unlock`; add if missing. + +--- + +## Testing + +Three layers, four new files plus the existing one: + +| File | Layer | Covers | +|---|---|---| +| `test/pytest/test_param_manager.py` | unit (local `ParameterManager`) | existing behaviour; update file-shape assertions to version 2 in Phase 4 | +| `test/pytest/test_pm_locks.py` | unit + proxy | `ManagedParameter`, Lock API, chains, cycles, deletion cleanup, `pm-lock-update` on the wire | +| `test/pytest/test_pm_types.py` | unit + proxy | Type registry, Nested Types, matching, edits and side effects, `add_instance`, Type Locks, `_globals`, `pm-type-update` and side-effect creation broadcasts | +| `test/pytest/test_pm_persistence.py` | unit | version-2 write/read, legacy read, validation errors, load order, profile switching | +| `test/pytest/test_broadcaster.py` | unit + server | `Broadcaster` mixin; server registers sinks for created and config-loaded instruments; non-Broadcaster instruments unaffected | +| `test/pytest/test_pm_gui.py` | pytest-qt | tabs, tints, lock toggle, arm flow, Locks panel, Types tab, live update from a second client | + +Conventions: proxy tests use the `param_manager` fixture; GUI tests copy the +`test_gui_navigation.py` pattern (own server on a fixed port ≥ 5600, `qtbot.waitUntil`). +Every error path decided above has a test asserting the exception type **and** that the +message lists every offending path. + +--- + +## Phases and tasks + +Each task: what to build, files touched, acceptance, tests. One task per session. + +### Phase 0 — Foundations + +- [ ] **0.1 `ParameterGroup` split.** In `params.py` create `ParameterGroup(InstrumentBase)` + holding parameters and nested groups with the tree helpers moved from `ParameterManager` + (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`/`_to_tree`, `list`, + `remove_empty_submodules`, the dotted `add_parameter`/`remove_parameter`/`get`/`set`). + `ParameterManager(ParameterGroup)` keeps profiles, files, `workingDirectory`, and (later) + Types/Locks. `_get_parent(..., create_parent=True)` creates `ParameterGroup(n)`. + `_to_tree` assertion changes from `isinstance(sm, ParameterManager)` to `ParameterGroup`. + Acceptance: creating `q01.IF` no longer lists the working directory or logs "parameter + file not found"; `isinstance(pm.q01, ParameterGroup)` and not `ParameterManager`. + Tests: `test_param_manager.py` all green unchanged; add `test_submodules_are_groups` and a + test with a `parameter_manager-q01.json` present in `tmp_path` proving it is **not** + loaded into the `q01` submodule. +- [ ] **0.2 `Broadcaster` mixin.** In `base.py` (next to `sendBroadcast`): class + `Broadcaster` with `add_broadcast_sink(fn)`, `remove_broadcast_sink(fn)`, + `broadcast(bp: ParameterBroadcastBluePrint)`; sinks stored in a list; exceptions in one + sink are logged and do not stop the others; no sinks → no-op. `ParameterManager` inherits + it (no emissions yet). Tests: `test_broadcaster.py` unit part. +- [ ] **0.3 Server registers sinks.** In `server/core.py`: helper + `_registerBroadcaster(instrument)` doing `hasattr(instrument, "add_broadcast_sink")` → + `instrument.add_broadcast_sink(self._broadcastParameterChange)`. Call it after + `self.station.add_component(new_instrument)` in `_createInstrument` and for every + component after the Station is loaded from config in `__init__`. Add a one-line comment + above `_instrument_locks` noting prose calls it the "instrument mutex" (ADR-0003); do not + rename. Tests: `test_broadcaster.py` server part — a dummy `Broadcaster` instrument created + through `cli.find_or_create_instrument`; calling a method on it that emits a blueprint is + received by a `SubClient`; a plain dummy instrument still works and gets no sink. +- [ ] **0.4 Pre-existing fixes (D24, first two).** `_newOrDeleteParameterDetection`: use + `kwargs.get("initial_value")` / `kwargs.get("unit", "")`. `apps.py:parameterManagerScript`: + pass `sub_port=args.port + 1` (and `sub_host="localhost"`) into `ParameterManagerGui`. + Tests: `test_apps.py` (extend the two existing param-manager launcher tests to assert + the kwargs); a proxy test in `test_param_manager.py` that `add_parameter("x")` with no + `initial_value`/`unit` succeeds and broadcasts. +- [ ] **0.5 Broadcast action constants.** In `blueprints.py`: `PARAMETER_UPDATE`, + `PARAMETER_CALL`, `PARAMETER_CREATION`, `PARAMETER_DELETION`, `PM_LOCK_UPDATE`, + `PM_TYPE_UPDATE` string constants; use them in `server/core.py`, `gui/instruments.py`, + `client/application.py`, `monitoring/listener.py` wherever the literals appear (grep + `"parameter-` across `src/`). No behaviour change. Tests: whole suite green. + +### Phase 1 — Locks + +- [ ] **1.1 `ManagedParameter`.** In `params.py`: `ManagedParameter(Parameter)` with + attribute `lock: PMLockBluePrint | None` plus a private reference to the Target parameter + object and a `locked` flag. `get_raw`: if locked → `return self._target.get()`; else own + cached value. `set_raw`: if locked → `raise ValueError(f"{full_name} is locked to + {target_full_name}")`; else store. Override `snapshot_base` so that while locked `value` is + the Target's value and a `lock` entry is present. Helper `own_value()` returning the cached + own value regardless of state. `ParameterManager.add_parameter` uses + `parameter_class=ManagedParameter`. Also define `PMLockBluePrint(target: str, locked: bool, + _class_type="PMLockBluePrint")` in `blueprints.py`. Tests: `test_pm_locks.py` unit part + with two standalone `ManagedParameter`s (no manager): get redirect, set raises with the + right message, unlocked exposes own value, snapshot values, cache untouched by locking. +- [ ] **1.2 Lock API on `ParameterManager`.** `lock`, `unlock`, `relock`, `toggle_lock`, + `remove_lock`, `get_lock`, `list_locks`, `followers_of` (D9), all validate-then-mutate: + unknown paths, self-lock, and cycles (walking Targets regardless of state, D7) raise with + messages naming the paths. `lock` on a parameter with an existing Lock re-targets (after + the cycle check). `remove_parameter` removes every Lock whose Target is the removed + parameter (D3) and then deletes. Chains behave per D7. Tests: `test_pm_locks.py` unit part. +- [ ] **1.3 `pm-lock-update` and proxy round-trip.** Emit `pm-lock-update` per D10 from every + Lock method and from the `remove_parameter` cleanup. Tests: `test_pm_locks.py` proxy part + via the `param_manager` fixture: every method callable through the proxy; `get_lock` / + `list_locks` deserialise to `PMLockBluePrint`; `pm.q02.x()` returns the Target's value over + the wire; a `SubClient` receives `pm-lock-update` with a `PMLockBluePrint` value, and + `None` after `remove_lock`. + +### Phase 2 — Types + +- [ ] **2.1 Type registry and definitions.** In `params.py`: internal dataclasses + `_TypeEntry(default, unit, target)` and `_TypeDefinition(name, parameters: dict[str, + _TypeEntry], nested: dict[str, str])`; registry `self._types` on the root only. + `add_type`, `remove_type` (raises if any other Type nests it), `list_types`, `get_type` + → `PMTypeBluePrint(name, parameters, nested, effective)` defined in `blueprints.py`. + `_effective_parameters(type)` expands Nested Types recursively under their submodule name; + raises on cycles (checked in `add_nested_type`) and on a path appearing twice. `_globals` + refused as a Type name. Tests: `test_pm_types.py` unit part (definitions, effective set, + cycle refusal, collision refusal, blueprint content). +- [ ] **2.2 Instance matching.** `instances_of(type)` and `types_of(path)` per D12 (existence + **and** unit; every submodule at any depth; never root; never under `_globals`; empty Type + → none). `types_of` orders innermost first (longest submodule path), then largest effective + set. Tests: `test_pm_types.py` — the three-tier case from the mock (`qubit` nests `readout` + nests `pulse_window`), unit mismatch excludes, extra parameters don't matter, two Types on + one submodule, `q01.readout` is an Instance of `readout` on its own. +- [ ] **2.3 Type edits with Instance side effects.** `add_type_parameter` (creates in every + Instance lacking it, with default and unit; raises if in the effective set already), + `remove_type_parameter`, `set_type_parameter_default`, `set_type_parameter_unit` + (propagates to every Instance's parameter), `add_nested_type` (writes missing entries under + the submodule into every Instance of the outer Type), `remove_nested_type`. All + validate-then-mutate. Tests: `test_pm_types.py` — each edit's effect table from D13, plus + a failing validation leaving the tree byte-identical (compare `list()` and values before + and after). +- [ ] **2.4 `add_instance`.** Per D14, including the up-front unit-conflict scan that raises + listing every conflicting path before creating anything, dotted (nested) names, and + `_globals` refusal. Tests: `test_pm_types.py`. +- [ ] **2.5 `pm-type-update` and side-effect broadcasts.** Every Type-editing method emits + `pm-type-update` (D22) with the updated `PMTypeBluePrint` (or `None` on `remove_type`). + Every parameter created by 2.3/2.4 emits `parameter-creation` through `self.broadcast`; + none is emitted for direct `add_parameter` calls (the server does those). Tests: + `test_pm_types.py` proxy part: all methods callable via proxy, `get_type` deserialises to + `PMTypeBluePrint`, a `SubClient` sees `pm-type-update` and one `parameter-creation` per + created parameter after `add_instance` from a second client, and the first client's + proxy shows the new parameters after `update()`. + +### Phase 3 — Type Locks and `_globals` + +- [ ] **3.1 `_globals` rules.** Per D18: `add_parameter` and `add_instance` under `_globals` + raise; `_globals` excluded from `instances_of`/`types_of` (already in 2.2, assert again); + internal helper `_ensure_global_target(type, path)` creating `_globals..` with + the entry's default and unit. Tests: `test_pm_types.py`. +- [ ] **3.2 `lock_type_parameter` / `unlock_type_parameter`.** Per D17: store `target` on the + entry; default Target via 3.1; put a locked Lock on every current Instance's parameter, + skipping those with a Lock on another Target (collect, `logger.warning`, return the list); + `unlock_type_parameter` clears the rule only. `add_instance` and completing + `add_type_parameter` apply existing rules to new parameters. `PMTypeBluePrint.parameters` + carries `target`. Both methods emit `pm-type-update` plus the `pm-lock-update`s of the + Locks they create. Tests: `test_pm_types.py` — apply, skip-with-warning (use `caplog`), + new Instance auto-locked, rule removal leaves Locks, Instance falling out keeps Locks, + re-declare re-applies, explicit `target=` pointing at an ordinary parameter. +- [ ] **3.3 Deletion interplay.** `remove_parameter` on a `_globals` parameter (or any Type + Lock Target) removes the Locks pointing at it **and** clears the Type Lock rule(s) whose + Target it was, emitting `pm-type-update` for each affected Type. `remove_type` drops its + rules but leaves `_globals` parameters and Instance Locks alone. Tests: `test_pm_types.py`. + +### Phase 4 — Persistence (version 2) + +- [ ] **4.1 Writer.** `toParamDict`/`toFile` produce the D19 layout: `version: 2`, + `parameters` (own values via `ManagedParameter.own_value()`, `unit`, `lock` only on + Followers, full paths as keys), `types` (parameters with default/unit/target, nested). + Keep `json.dump(..., indent=2, sort_keys=True)`. Update `serialize.validateParamDict` + callers accordingly (validate the `parameters` map with the existing schema) and add a + `schemas/parameter_manager_v2.json`. Update the flat-shape assertions in + `test_param_manager.py` to `["parameters"][...]`. Tests: `test_pm_persistence.py` writer + cases; `test_param_manager.py` green. +- [ ] **4.2 Reader.** `fromParamDict`/`fromFile`: detect legacy (no `version`) vs 2; validate + the whole document first (every `lock.target` and every Type `target` must be a key in + `parameters`; otherwise `ValueError` listing **all** missing Targets, state untouched); + then load parameters (existing `deleteMissing` semantics), then Types without Instance + side effects, then Locks (locked/unlocked as stored). Tests: `test_pm_persistence.py` — + legacy fixture file loads; round-trip equality; missing Targets error lists every one and + leaves the previous state intact; partial Instances are not "completed" on load. +- [ ] **4.3 Profiles.** `remove_all_parameters` also clears Types and Locks (or add + `_clear_all()` used by `switch_to_profile`); `switch_to_profile` = save → clear → load. + `refresh_profiles`/`list_profiles` unchanged. Tests: `test_pm_persistence.py` — switching + between a profile with Types and one without leaves no Type or Lock behind; existing + switching tests in `test_param_manager.py` green. + +### Phase 5 — GUI + +- [ ] **5.1 Client-side state and broadcast handling.** In `gui/instruments.py`: a small + `PMState` helper on `ParameterManagerGui` holding `types: dict[str, PMTypeBluePrint]` and + `locks: dict[str, PMLockBluePrint]`, filled by `list_types`/`get_type`/`list_locks` on + load and refresh; `ModelParameters.updateParameter` routes `pm-lock-update` and + `pm-type-update` to new signals (`lockChanged(path, PMLockBluePrint|None)`, + `typeChanged(name, PMTypeBluePrint|None)`) which update `PMState`. Fix D24 item three: + `ParameterManagerTreeView.onItemNewValue` uses `widget._setMethod(value)`. Tests: + `test_pm_gui.py` — construct `ParameterManagerGui` against a live server; a second client + locks a parameter; `qtbot.waitUntil` the state holds it. +- [ ] **5.2 Tabs, tints and gutter bands.** Wrap the existing widget in a `QTabWidget` + (Parameters, Types; Types tab empty for now). Port the mock's `claims()` to a pure + function over `PMState.types` + the model's paths, producing per row: claiming Type, + stack of up to 3 Types. Palette of 5 tint pairs + bar colours assigned by Type creation + order. Apply tint via `BackgroundRole` on all columns and draw the gutter band(s) in a + dedicated first column with a tiny delegate. Recompute on `typeChanged` and on model + reload. Tests: `test_pm_gui.py` — after `add_type` + entries from a second client, rows + under a matching submodule carry the Type's tint; after `remove_type_parameter`, the + parameter's row loses it. +- [ ] **5.3 Lock column, toggle, context menu, arm strip.** New model column "locked to" + (text: `locked to ` / `unlocked · ` / `target ×N` from `followers_of` + computed client-side over `PMState.locks`). Per-row lock button in the delegate widget + (visible when a Lock exists; purple fill while locked) calling `toggle_lock`. Context menu + entries "Lock to…" and "Unlock" beside star/trash. Arm strip under the toolbar: label, + `QLineEdit` + `QCompleter` over all paths ranked like the mock, Cancel; clicking a row + while armed picks it; Esc cancels; on pick call `lock(follower, target)` and show the + server's error text in the strip if it raises. Locked rows render their value read-only. + On `parameter-update` for X, also refresh every row whose Lock Target chain reaches X. + Tests: `test_pm_gui.py` — arm via context menu, pick a row, assert `get_lock` on the + server; toggle unlocks; a cycle attempt shows the error; setting the Target from a second + client repaints the Follower row. +- [ ] **5.4 Locks panel.** Toolbar action (lock icon, checkable, `Ctrl+Shift+L`) toggling a + second `QTreeView` in a `QSplitter` right of the tree. Rows built from `PMState.locks`: + Targets at depth 0 (Type Lock Targets first, labelled `[type: ] `), Followers + beneath, recursively for chains. Per Follower row: lock/relock toggle and remove + (`remove_lock`). Per Target row: value editor with set button (plain `set` on the Target); + for Type Lock rows: "lock all" (`lock_type_parameter` again) and "remove rule" + (`unlock_type_parameter`). "Lock selection to…" button arms for the tree's current row. + Tests: `test_pm_gui.py` — panel rows reflect `list_locks`; remove from panel removes on + server; live update when a second client locks. +- [ ] **5.5 Types tab.** Three panes per the Design reference: type list (name, #instances, + #params; New type strip → `add_type`); entries of the selected Type as a tree (own entries + editable default → `set_type_parameter_default`, Remove → `remove_type_parameter`, Type + Lock toggle → `lock_type_parameter`/`unlock_type_parameter`, re-target via arm; entries + from Nested Types shown read-only with "defined by "; nested submodule rows show + `type: ` and Remove → `remove_nested_type`); "Add to type" strip + (`add_type_parameter`); "Nested type … at submodule …" strip (`add_nested_type`); + instances pane (name, #parameters, "also ", Show → switch to Parameters tab, + expand and select; New instance strip → `add_instance`). Skipped-Lock warnings from + `lock_type_parameter` shown in the pane's note line. Live update on `typeChanged`. Tests: + `test_pm_gui.py` — create a Type and an Instance through the widgets; server state + matches; second-client edits appear. +- [ ] **5.6 Delete-Target confirmation and polish.** Deleting a parameter (row delete button + or shortcut) that has Followers pops a `QMessageBox` listing the Locks that will be removed + (from `followers_of`) with OK/Cancel. Verify `resource.qrc` has `lock`/`unlock`; add + shortcuts to `gui/shortcuts.py` for the new actions following its conventions; run + `instrumentserver-param-manager` against a server on a non-default port and confirm live + updates end to end (manual check, note the result under this task). Tests: + `test_pm_gui.py` — the confirmation appears and Cancel leaves the server untouched. + +### Phase 6 — Documentation + +- [ ] **6.1 User Guide: `docs/user_guide/parameter_manager.md`.** Following the docs + protocol: concept and single source of truth; hierarchical parameters; Types (shape, + Instances, Nested Types, `add_instance`); Locks (states, chains, what `set` does); Type + Locks and `_globals`; profiles and the version-2 file (with the legacy note); the GUI + (tabs, tints, lock column, arm strip, Locks panel, Types tab) with screenshots; using it + from measurement code. Verification script + `test/docs_verification/user_guide/parameter_manager.py`. Zero Sphinx warnings. +- [ ] **6.2 Technical Guide: `docs/technical_guide/broadcasts.md`.** What triggers a + Broadcast and the wire format; `SubClient`; external forwarding; the **Broadcaster + contract** (mixin, registration points, threading, no-op standalone) and the Parameter + Manager's actions with their payload blueprints. Verification script under + `test/docs_verification/technical_guide/`. Cross-link with the User Guide page. +- [ ] **6.3 Bookkeeping.** Update `PLAN_docs_refactor.md` (mark the two pages done, adjust + their section lists to what was written), `TEST_AUDIT.md` (gaps noticed, defects left), + and do a final pass of `CONTEXT.md` and the three ADRs against the shipped behaviour. + +--- + +## Notes for later (not tasks yet) + +- The Broadcaster contract makes an `instrument-structure-changed` broadcast trivial if a + future Virtual Instrument needs it; do not add it speculatively. +- `ServerResponse.__init__` munges string messages (`'`→`"`, `T`→`t`, …) before + `json.loads`. Blueprint dataclasses avoid it; keep return values as blueprints or plain + JSON types, never free-form strings. +- If `types_of` performance ever matters (thousands of parameters), cache the effective sets + per Type and invalidate on Type edits; matching itself stays a tree walk. diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md new file mode 100644 index 0000000..05ce0eb --- /dev/null +++ b/TEST_AUDIT.md @@ -0,0 +1,95 @@ +# Audit Tracker — Docs Refactor + +Tracker for gaps discovered while writing verified documentation +(see `PLAN_docs_refactor.md`, Workflow step 5 — AUDIT). Two kinds of gaps: +test coverage and docstring quality. + +## Tests + +States: + +- **covered** — existing pytest covers it (note which test) +- **gap** — documented behavior has no pytest coverage; test needed +- **issue-filed** — gap converted to a tracked issue / test PR +- **waived** — deliberately untested, with a written reason + +| Page | Section | Behavior / claim | Verification source | State | Coverage / notes | +|------|---------|-------------------|---------------------|-------|------------------| +| server.md (future) | Starting headless | `instrumentserver --gui False` starts headless and serves requests. Quirk: only the literal string `False` disables the GUI (`args.gui == "False"` string compare, `apps.py:90`); `--gui false` or `--gui 0` would still start the GUI | helpers.py `server_process()` (smoke-tested during Phase 0) | gap | No pytest covers CLI launch or flag parsing; the string compare deserves a test and probably an argparse fix | +| api/index.md | Autosummary build | All 13 public modules (and their submodules) import without side effects; required by autodoc and by any consumer. Found during Phase 1: `testing/create_instrument.py` ran a module-level `InstrumentClient()` on import, hanging any recursive import of `instrumentserver.testing` when no Server runs. Orphaned (GitNexus: zero dependents) and buggy (missing comma concatenated two string args); deleted with Marcos's approval | The docs build itself + a watchdog import check of all 13 modules | gap | No pytest imports all public modules; a trivial import smoke test would have caught this years ago | +| installation.md | Install from git (non-editable) | A wheel/sdist build must include the package data dirs (`schemas/`, `deployment/`) or the package cannot even be imported (`__init__.py` opens `schemas/parameters.json` at import time). Found during Phase 2: `[tool.setuptools.package-data]` was missing entirely, so `uv add git+...` produced an uninstallable-in-practice package; fixed in `pyproject.toml`. Editable installs masked this for years | Manual smoke test: `uv add` from a built wheel, import + `paramDictSchema` loaded, data dirs present (Phase 2 grill session) | gap | No CI step builds the wheel and imports from it; an `uv build` + import smoke test would catch any future package-data regression. Also blocks the planned PyPI release if it regresses | +| quickstart.md | Starting with a config file | `loadConfig` (`config.py`) parses the YAML and the server creates the declared instruments at startup. Found during Phase 2: an empty or comments-only config file makes `yaml.load()` return `None`, and `loadConfig` then raises a confusing `TypeError: argument of type 'NoneType' is not iterable` instead of a clear "file is empty" message | quickstartConfig.yml via `server_process(config=...)` in verify_quickstart.py | issue-filed | Filed upstream as toolsforexperiments/instrumentserver#139 (related to #109); no pytest covers `loadConfig`'s error paths | +| quickstart.md; how_it_works.md | Get/set a parameter / Broadcast reaches subscribers | End-to-end Broadcast: a parameter set on a live `StationServer` is emitted and received by subscribing `SubClient` instances. The how-it-works page also claims that one Broadcast fans out to every listening subscriber | `section_get_set_broadcast` (real server + `capture_broadcasts`/`SubClient`) in verify_quickstart.py | gap | `test_base.py` covers only the pieces in isolation: blueprint encode/decode and `sendBroadcast`/`recvMultipart` over a raw zmq pub/sub pair. Nothing drives a real server set → SubClient receive, and no test proves fan-out to two subscribers. The integration path is untested; strongest candidate for a new integration test | +| quickstart.md | Connect a client and create an instrument | `find_or_create_instrument` is idempotent: a second call with the same name returns a Proxy for the existing instrument and does not import or compare the supplied class path | `section_first_client` in `verify_quickstart.py`; `section_connect_and_get_an_instrument` in `verify_client.py` | covered | `test_connection_discovery_and_shared_server_state` covers the name-based find branch with an intentionally invalid class path | +| quickstart.md | Connect a client / starting bare | A freshly started Server with no config owns no instruments: `list_instruments() == []` | `section_start_bare` in `verify_quickstart.py`; `section_connect_and_get_an_instrument` in `verify_client.py` | covered | `test_connection_discovery_and_shared_server_state` asserts the empty initial list on the module's fresh Server | +| quickstart.md; how_it_works.md | Starting with a config file / Server owns the instrument | End-to-end server-from-config: starting a real server with `-c ` makes instruments marked `initialize: True` exist the moment it is up, with no client creation call | `section_config_file` (real `instrumentserver -c` subprocess) in verify_quickstart.py | gap | `test_apps.py` *mocks* `loadConfig` and asserts only the `server()` call wiring; `test_config.py` covers parsing only. No test starts a real server from a config and confirms the declared instrument is actually present and usable | +| quickstart.md | Connect / create (example instrument) | The `rf.Generator` dummy instantiates with `frequency` 10 GHz, `power` -100, `rf_on` False, and parameters `IDN`, `frequency`, `power`, and `rf_on` | `section_first_client` in `verify_quickstart.py`; `section_connect_and_get_an_instrument` in `verify_client.py` | covered | `test_connection_discovery_and_shared_server_state` asserts the exact initial values and parameter set | +| how_it_works.md | Server owns the instrument / call reaches the hardware | The Server-owned instrument and its state survive the Client that created it: Client A creates and sets an instrument, disconnects, and Client B retrieves the existing instrument and reads the same state | `section_connect_and_get_an_instrument` in `verify_client.py` | covered | `test_connection_discovery_and_shared_server_state` disconnects the creating Client before a context-managed second Client reads the value | +| how_it_works.md | Broadcast reaches subscribers | A real parameter Broadcast received by the Server GUI updates the displayed value | Conceptual page; no page-specific verification script by decision | gap | `test_server_gui.py` exercises GUI creation, refresh, save/load, and opening tabs, but never drives a live parameter change through the Broadcast path and asserts the displayed value changes | +| how_it_works.md | Broadcast reaches subscribers | A Listener receives a real parameter Broadcast and records the change in its sink | Conceptual page; no page-specific verification script by decision | gap | The pytest suite has no Listener tests. `test_base.py` covers only raw PUB/SUB transport, not `Listener.run()` or the CSV/Influx listener behavior | +| client.md | Connections and instrument discovery | CLI port/address options, default loopback access, explicit and context-managed lifecycle, name-based lookup, no-handshake construction, permanent disconnection, and two Clients observing the same Server state | `section_connect_and_get_an_instrument` in `verify_client.py` | covered | `test_server_script_passthrough_args`, `test_server_script_gui_default_no_config`, `test_connection_discovery_and_shared_server_state`, and `test_construction_does_not_handshake_and_disconnect_is_permanent` | +| client.md | Proxy Instrument lifecycle | The Blueprint survives its response codec before Proxy construction. A `FieldVector` crosses the request and response boundaries as distinct reconstructed objects | `section_use_a_proxy_instrument` in `verify_client.py` | covered | `test_basic_instrument_dictionary` covers Blueprint reconstruction; `test_sending_and_receiving_arbitrary_objects` asserts the Client, Server, and returned `FieldVector` identity boundaries | +| client.md | Proxy metadata and mutable interface | Proxy Parameters expose Blueprint metadata and QCoDeS get/set behavior; method signatures and docstrings are copied; nested parameters and methods keep their paths; `update()` synchronizes parameters and submodules and adds methods without removing stale installed methods | `section_use_a_proxy_instrument` in `verify_client.py` | covered | `test_proxy_metadata_methods_submodules_and_update` covers the complete current contract with `MutableInterfaceInstrument` | +| client.md | Values that cross the connection | Built-in values, NumPy arrays, custom `SweepRequest`/`SweepResult` objects, tuple/set conversion, top-level and nested Enum behavior, and numeric-text coercion follow the documented transport rules | `section_use_a_proxy_instrument` in `verify_client.py` | covered | `test_transport_values_and_custom_dataclass_flow`, `test_custom_dataclass_request_and_result_codec`, `test_scalar_text_coercion`, and `test_enum_serialization.py` | +| client.md | Custom serialization limits | Custom classes must be importable and accept each listed attribute in the constructor. Nested custom objects and NumPy arrays stored as custom-object fields are not recursively encoded | `section_use_a_proxy_instrument` in `verify_client.py` | covered | `test_custom_serialization_requirements_and_non_recursive_fields` covers import, constructor, nested-object, and NumPy-field failures | +| client.md | Proxy Instrument lifecycle example | `FieldVectorIns` supplies a minimal `*IDN?` response, so QCoDeS construction no longer logs the former `NotImplementedError` traceback | `section_use_a_proxy_instrument` in `verify_client.py` | covered | `test_field_vector_dummy_responds_to_idn_without_error_log` checks the parsed response and captured log | +| gui_features.md (future) | GUI model population | `InstrumentModelBase.addItem` (`gui/base_instrument.py:300`) drives all three GUI population flows (`__init__`, `refreshAll`, `updateParameter` → `insertItemTo` / star / trash handling) per GitNexus; impact analysis with `includeTests` finds no test at any depth | Found during Phase 1 change-detection review (no docs script yet; behavior not yet documented) | gap | `test_server_gui.py` / `test_client_station.py` exercise the path only indirectly, nothing asserts addItem's hierarchy building; natural candidate for a direct model test when gui_features.md is verified | +| client.md | Parameter snapshots | Relative Client-side paths, selected and all-instrument save/restore, flat and nested shapes, and nested `setParameters` rejection match the guide. Native JSON booleans still become `0.0`/`1.0` and fail QCoDeS Boolean validation | `section_save_and_restore_parameter_values` in `verify_client.py` | covered | `test_parameter_snapshot_files_and_current_boolean_limitation` covers the end-to-end workflow and explicitly references open product bug #152. The issue remains open and is not fixed in this documentation pass | +| client.md | Errors and timeouts | Server validation failures arrive as generic `Exception` objects. A timeout discards the old socket, connects a replacement without retrying, lets the original Server call finish exactly once, and permits later requests. `raise_exceptions=False` logs and returns `None`; `disconnect()` is permanent | `section_handle_errors_and_timeouts` in `verify_client.py` | covered | `test_server_errors_timeout_socket_replacement_and_quiet_mode` and `test_timeout_dummy_responds_to_idn` | + +## Manual checks + +| Page | Check | State | Notes | +|------|-------|-------|-------| +| client.md | Reused Server GUI screenshots | checked | Reviewed `server_generator_light.png` and `server_generator_dark.png` at original resolution. Both show the generator in the Station view, remain legible, and match their selected themes | +| client.md | Proxy lifecycle animation | waived | The clean build includes `proxy_lifecycle.html`. No browser session was available for step-by-step interaction review, and Marcos accepted the animation at page closeout | +| client.md | External links | checked | Both QCoDeS API targets resolve to their current documentation. GitHub issue #152 resolves and remains open as "Boolean parameter values deserialize as floats during restoration" | + +## Docstrings + +States: + +- **ok** — accurate and current (checked, no action) +- **gap** — missing, wrong, or stale docstring on a symbol referenced by a docs page +- **fixed** — corrected (note the commit/PR) +- **waived** — left as-is, with a written reason + +| Symbol | Referenced by page | Problem | State | Notes | +|--------|--------------------|---------|-------|-------| +| `blueprints.iterable_to_serialized_dict` | API (Phase 0 zero-warnings pass) | Malformed RST bullet list broke the Sphinx build | fixed | Reformatted in working tree (Phase 0) | +| `server.application.CreateInstrumentDialog.createInstrument` | API (Phase 0 zero-warnings pass) | Signal had no `#:` doc comment; autodoc fell back to PyQt's malformed `pyqtSignal.__doc__` | fixed | Doc comment added (Phase 0) | +| `server.application.PossibleInstrumentsDisplay.createButtonPressed` | API (Phase 0 zero-warnings pass) | `#:` doc comment interrupted by plain `#` lines, breaking autodoc pickup | fixed | Continuation lines changed to `#:` (Phase 0) | +| `server.application.PossibleInstrumentsDisplay.basedInstrumentRequested` | API (Phase 0 zero-warnings pass) | Same interrupted `#:` block as above | fixed | Continuation lines changed to `#:` (Phase 0) | +| `server.application.ServerGui.serverPortSet` | API (Phase 0 zero-warnings pass) | No doc comment; also the signal is declared but never emitted or connected anywhere | fixed | Doc comment added noting it is unused; candidate dead code, consider removing | +| `gui.base_instrument` (module docstring) | api/index.md (Phase 1 zero-warnings pass) | Misaligned bullet-list continuation lines broke RST parsing (3 warnings) | fixed | Reformatted in working tree (Phase 1) | +| `gui.base_instrument.InstrumentModelBase.addItem` | api/index.md (Phase 1 zero-warnings pass) | Bare `*args`/`**kwargs` parsed as RST emphasis/strong | fixed | Wrapped in double backticks (Phase 1) | +| `gui.instruments.ModelParameters.itemNewValue` | api/index.md (Phase 1 zero-warnings pass) | `# :` instead of `#:` so autodoc fell back to PyQt's malformed `Signal.__doc__` | fixed | Doc comment corrected (Phase 1) | +| `log.QLogHandler.new_html` | api/index.md (Phase 1 zero-warnings pass) | Signal had no `#:` doc comment; autodoc fell back to malformed `Signal.__doc__` | fixed | Doc comment added (Phase 1) | +| `testing.dummy_instruments.generic.DummyInstrumentWithSubmodule.ask_raw` | api/index.md (Phase 1 zero-warnings pass) | Bare `*IDN?` parsed as RST emphasis start | fixed | Wrapped in double backticks (Phase 1) | +| `instrumentserver.apps` (module) | api/index.md (Phase 1 triage) | No module docstring; autosummary landing page shows a blank summary cell | gap | One- or two-line summary needed | +| `instrumentserver.base` (module) | api/index.md (Phase 1 triage) | No module docstring; blank summary cell | gap | | +| `instrumentserver.client` (module) | api/index.md (Phase 1 triage) | No module docstring (package `__init__`); blank summary cell | gap | | +| `instrumentserver.gui` (module) | api/index.md (Phase 1 triage) | No module docstring (package `__init__`); blank summary cell | gap | | +| `instrumentserver.helpers` (module) | api/index.md (Phase 1 triage) | No module docstring; blank summary cell | gap | | +| `instrumentserver.monitoring` (module) | api/index.md (Phase 1 triage) | No module docstring (package `__init__`); blank summary cell | gap | | +| `instrumentserver.params` (module) | api/index.md (Phase 1 triage) | No module docstring; blank summary cell | gap | | +| `instrumentserver.server` (module) | api/index.md (Phase 1 triage) | No module docstring (package `__init__`); blank summary cell | gap | | +| `instrumentserver.testing` (module) | api/index.md (Phase 1 triage) | No module docstring (package `__init__`); blank summary cell | gap | | +| `instrumentserver.config` (module) | api/index.md (Phase 1 triage) | Docstring starts mid-thought ("If any instrument in the config does not have values for SERVERFIELDS...") — not a summary line, so the autosummary cell is confusing | gap | Needs a proper first-line summary; existing detail can stay below it | +| `instrumentserver.serialize` (module) | api/index.md (Phase 1 triage) | Docstring first line is just "instrumentserver.serialize" — autosummary cell repeats the module name | gap | Needs a real one-line summary | +| `apps.server/serverWithGui/serverScript/parameterManagerScript/detachedServerScript/clientStationScript` | api/index.md (Phase 1 skim) | All six console entry-point functions lack docstrings; these are the package's front doors | gap | Worst offender of the skim | +| `client.proxy.ClientStation` | api/index.md (Phase 1 skim) | Major public class, no class docstring | gap | | +| `base.encode/decode/send/recv/send_router/recv_router` | api/index.md (Phase 1 skim) | The core messaging primitives have no docstrings | gap | | +| `blueprints.bluePrintFromParameter/bluePrintFromMethod/bluePrintFromInstrumentModule` | api/index.md (Phase 1 skim) | Blueprint factory functions undocumented | gap | | +| `gui.getStyleSheet/widgetDialog/widgetMainWindow/keepSmallHorizontally` | api/index.md (Phase 1 skim) | Package-level GUI helpers undocumented | gap | | +| `helpers.typeClassPath/objectClassPath` | api/index.md (Phase 1 skim) | No docstrings | gap | | +| `monitoring.listener.checkInfluxConfig/checkCSVConfig/get_timezone_info/startListener` | api/index.md (Phase 1 skim) | Listener config/startup functions undocumented | gap | | +| `params.paramTypeFromVals/paramTypeFromName` | api/index.md (Phase 1 skim) | No docstrings | gap | | +| `serialize.validateParamDict` | api/index.md (Phase 1 skim) | No docstring | gap | | +| `client.core.sendRequest` | api/index.md (Phase 1 skim) | No docstring | gap | | +| `server.application.bluePrintToHtml/parameterToHtml/instrumentToHtml` | api/index.md (Phase 1 skim) | No docstrings | gap | +| `client.proxy.Client.find_or_create_instrument` | quickstart.md (connect/create section) | Accurate and complete, but two small blemishes: duplicated word ("a string of\n of the class") and `:returns: A new virtual instrument` ignores the find branch, which returns a proxy to an *existing* instrument | gap | Source docstring edits were explicitly excluded after the Client audit | +| `client.proxy.Client` | how_it_works.md | The one-line docstring says only "Client with common server requests as convenience functions." It does not explain the Client's connection, discovery, or Proxy-construction role | gap | Source docstring edits were explicitly excluded after the Client audit | +| `blueprints.InstrumentModuleBluePrint` | how_it_works.md | The docstring calls the Blueprint a "Spec" and does not explain that it describes parameters, methods, and submodules used to construct a Proxy Instrument | gap | Source docstring edits were explicitly excluded after the Client audit | +| `client.proxy.ProxyInstrumentModule` | how_it_works.md | The docstring calls the proxy a "virtual module," contains the typo "instrument of submodule of instrument," and does not explain that calls are forwarded to the Server-owned instrument | gap | Source docstring edits were explicitly excluded after the Client audit | +| `monitoring.listener.Listener` | how_it_works.md | Public abstract base class has no class docstring | gap | Add a short description of subscribing to Broadcasts and forwarding them to a sink | diff --git a/TODO_type_cleanup.md b/TODO_type_cleanup.md new file mode 100644 index 0000000..d4a5322 --- /dev/null +++ b/TODO_type_cleanup.md @@ -0,0 +1,532 @@ +# Deferred type-cleanup: `InstrumentModelBase` parent/filter types + +During the mypy sweep, roughly 10 `# type: ignore` comments were concentrated +around `InstrumentModelBase.addItem` / `insertItemTo` / `fillCollapsedDict` in +`src/instrumentserver/gui/base_instrument.py`. They are all symptoms of two +underlying design issues that are worth fixing properly rather than suppressing. + +## Problem 1: mutable-default args + wrong Optional annotation + +`InstrumentModelBase.__init__` (around line 195): + +```python +def __init__( + self, + ... + itemsStar: Optional[List[str]] = [], + itemsTrash: Optional[List[str]] = [], + itemsHide: Optional[List[str]] = [], + ... +): + ... + self.itemsStar = itemsStar + self.itemsTrash = itemsTrash + self.itemsHide = itemsHide +``` + +Two bugs: + +1. **Mutable default argument.** All callers that don't pass these kwargs share + the *same* `[]` instance. If anyone ever mutates `self.itemsHide`, the change + leaks into every subsequent instance. No test currently catches this. +2. **Wrong `Optional`.** The annotation says "could be `None`", but the default + is `[]` and the consumer (`_matches_any_pattern`) requires `List[str]`. Mypy + flags every use site as `arg-type` because it sees `List[str] | None`. + +### Fix + +```python +def __init__( + self, + ... + itemsStar: Optional[List[str]] = None, + itemsTrash: Optional[List[str]] = None, + itemsHide: Optional[List[str]] = None, + ... +): + ... + self.itemsStar: List[str] = itemsStar if itemsStar is not None else [] + self.itemsTrash: List[str] = itemsTrash if itemsTrash is not None else [] + self.itemsHide: List[str] = itemsHide if itemsHide is not None else [] +``` + +The stored attributes are now `List[str]` (non-optional), so +`_matches_any_pattern(name, self.itemsHide)` type-checks without ignores. + +## Problem 2: `parent` is overloaded to be either the model or an item + +In `addItem` (around line 298): + +```python +parent = self # InstrumentModelBase (a QStandardItemModel) +for sm in path: + ... + if len(items) == 0: + parent = subModItem # ItemBase (a QStandardItem) + else: + parent = items[0] # QStandardItem +``` + +`insertItemTo` declares `parent: QStandardItem`, but the root case passes +`self` (the model). Internally, `insertItemTo` already handles both: + +```python +if parent == self: + self.setItem(self.rowCount(), 0, item) +else: + parent.appendRow(item) +``` + +So the signature is a lie — it actually accepts a union. Mypy flags: +- every reassignment of `parent` (`[assignment]`) +- every call to `insertItemTo(parent, ...)` (`[arg-type]`) +- the same pattern repeats in `fillCollapsedDict` when passing items through + +### Fix + +Make the signature honest: + +```python +def insertItemTo( + self, + parent: Union["InstrumentModelBase", QtGui.QStandardItem], + item: QtGui.QStandardItem, +) -> None: + if parent == self: + self.setItem(self.rowCount(), 0, item) + else: + assert isinstance(parent, QtGui.QStandardItem) + parent.appendRow(item) +``` + +And in `addItem`, give `parent` an explicit union annotation: + +```python +parent: Union["InstrumentModelBase", QtGui.QStandardItem] = self +``` + +All `[arg-type]` / `[assignment]` ignores on `addItem`, `insertItemTo`, and +`fillCollapsedDict` drop out. + +## Problem 3: `Lock` vs `RLock` type clash in `_createInstrument` + +In `src/instrumentserver/server/core.py` around lines 448-455: + +```python +lock = self._get_lock_for_target(spec.name) # Optional[threading.RLock] +if lock is None: + lock = ( + self._instrument_locks_lock # threading.Lock() (line 189) + ) + +with lock: + ... +``` + +`_get_lock_for_target` returns `Optional[RLock]`, so mypy infers `lock` as +`RLock | None`. The fallback then reassigns `Lock` into it — `Lock` is not +`RLock` (they're separate classes in `threading`), so mypy flags +`[assignment]`. Because the `[assignment]` ignore suppresses the reassignment, +mypy never narrows out the `None`, so `with lock:` also needs +`[union-attr]`. Two ignores stack up on what is really one design issue. + +### Fix + +Change `_instrument_locks_lock` at line 189 to `RLock`: + +```python +self._instrument_locks_lock = threading.RLock() +``` + +Then the fallback block becomes type-consistent, and we can simplify the +callsite to one line with an explicit annotation: + +```python +lock: threading.RLock = ( + self._get_lock_for_target(spec.name) or self._instrument_locks_lock +) +with lock: + ... +``` + +`RLock` is a strict superset of `Lock` (re-entrant from the same thread) and +`_instrument_locks_lock` is only acquired non-recursively at line 631, so the +swap is behavior-preserving. Both ignores drop out. + +## Problem 4: `isinstance(obj, tuple(LIST_CONST))` defeats mypy narrowing + +In `src/instrumentserver/server/core.py` around lines 518-529: + +```python +obj = nestedAttributeFromString(self.station, path) # returns Any +if isinstance(obj, tuple(INSTRUMENT_MODULE_BASE_CLASSES)): + instrument_blueprint = bluePrintFromInstrumentModule(path, obj) # arg-type error +elif isinstance(obj, tuple(PARAMETER_BASE_CLASSES)): + parameter_blueprint = bluePrintFromParameter(path, obj) # arg-type error +``` + +The constants are defined in `blueprints.py:74-77` as **lists**: + +```python +INSTRUMENT_MODULE_BASE_CLASSES = [Instrument, InstrumentChannel, InstrumentBase] +PARAMETER_BASE_CLASSES = [Parameter, ParameterWithSetpoints] +``` + +`isinstance(x, tuple(some_list))` succeeds at runtime, but mypy cannot inspect +the elements of a runtime-constructed tuple. Result: `obj` does not narrow — +it stays as `object` — so passing it to `bluePrintFromInstrumentModule` +(which expects `Instrument | InstrumentChannel | InstrumentBase`) triggers +`[arg-type]`. Same story at the `PARAMETER_BASE_CLASSES` branch. Also occurs +in `blueprints.py:340` (`isinstance(o, tuple(PARAMETER_BASE_CLASSES))`) in +`bluePrintFromInstrumentModule` itself, though that site currently happens to +type-check because `o` is already `object`. + +### Fix + +Inline the class tuples as literals at the `isinstance` callsites so mypy can +narrow: + +```python +# server/core.py +if isinstance(obj, (Instrument, InstrumentChannel, InstrumentBase)): + # obj narrows to Instrument | InstrumentChannel | InstrumentBase + instrument_blueprint = bluePrintFromInstrumentModule(path, obj) + ... +elif isinstance(obj, (Parameter, ParameterWithSetpoints)): + # obj narrows to Parameter | ParameterWithSetpoints + parameter_blueprint = bluePrintFromParameter(path, obj) + ... +``` + +The module-level `*_BASE_CLASSES` lists can remain — they're still used by +the blueprint-matching loops in `blueprints.py:123, 306` — but narrowing +callsites have to inline the tuple literal. If the lists are considered the +"source of truth" for which classes are instrument/parameter bases, this +creates a small duplication; acceptable trade-off for real type narrowing. + +## Catalog of all `# type: ignore` ignores by pattern + +The mypy sweep left **208 `# type: ignore` comments** across `src/`. Raw counts +by error code: + +| code | count | +|------------------------------|-------| +| `union-attr` | 91 | +| `arg-type` | 48 | +| `attr-defined` | 31 | +| `override` | 16 | +| `assignment` | 7 | +| `type-var` | 3 | +| `misc` | 2 | +| `call-overload` | 2 | +| `operator` | 1 (+1 combined) | +| `return-value` | 1 | +| `no-untyped-def` | 1 | +| `method-assign` | 1 | +| `import-not-found` | 1 | +| `assignment,method-assign` | 1 | + +Grouped by root cause: + +### Bucket A — Qt event-handler `[override]` (16 ignores) + +PyQt5 stubs declare event args as `QEvent | None`; our overrides use +`QEvent`. Same fix pattern for all of them. + +**Sites:** `gui/misc.py:38, 42, 77, 98, 108, 112, 156, 165, 169`; +`gui/instruments.py:795`; `client/application.py:373`; +`client/proxy.py:276` (`add_parameter` override, different class); +`gui/instruments.py:260, 458, 683` (`createEditor` override); +`params.py:203` (`add_parameter` LSP violation). + +**Fix (event handlers):** change signature to `Optional[QEvent]`. Most bodies +immediately use the event, so add an `if a0 is None: return super().foo(a0)` +guard or `assert a0 is not None`. Accept that `assert` technically changes +behavior under `-O`; if acceptable, ignores drop. + +**Fix (`add_parameter`/`createEditor` LSP):** these override qcodes/Qt +signatures with incompatible types. Leaving `# type: ignore[override]` is +the pragmatic choice — fixing properly would require wider refactors in +qcodes/Qt inheritance. + +### Bucket B — PyQt5 "Optional return" widget/item stubs (~70 `union-attr`) + +PyQt5's stubs conservatively mark many widget-returning methods as +`Optional` (e.g. `QMainWindow.addToolBar()` → `QToolBar | None`, +`QTreeView.header()` → `QHeaderView | None`, `QApplication.primaryScreen()` +→ `QScreen | None`, `QStandardItemModel.itemFromIndex()` → +`QStandardItem | None`, `QAbstractProxyModel.sourceModel()` → +`QAbstractItemModel | None`, `QWidget.parentWidget()` → `QWidget | None`, +`QTabWidget.widget(i)` → `QWidget | None`, `QTreeWidgetItem.parent()` → +`QTreeWidgetItem | None`, `QTextEdit.verticalScrollBar()` → `QScrollBar | +None`). At runtime they are never None in this codebase. + +**Sites (bulk):** `gui/base_instrument.py:152, 153, 554, 555, 614, 619, 620, +624, 627, 630, 645-671 (many), 689, 691, 694, 695, 704, 706, 832, 840, 846, +853, 854, 859, 860`; `gui/misc.py:127, 158, 160, 206, 244, 257`; +`gui/instruments.py:517, 592, 598, 696`; `gui/__init__.py` (none now); +`client/application.py:172, 246, 248, 342, 344`; `log.py:53, 54`; +`server/application.py:417, 418, 423, 424, 647, 650, 655, 658, 659, 665, +671, 712, 738, 876, 936, 939, 944`. + +**Fix:** assign the nullable return to a local, assert/narrow once: + +```python +toolbar = self.addToolBar("Tools") +assert toolbar is not None +toolbar.setIconSize(...) # no ignore needed +toolbar.addAction(...) +``` + +Tedious but eliminates the single biggest bucket. Same pattern applies to +`header()`, `verticalScrollBar()`, `parentWidget()`, `sourceModel()`, etc. + +### Bucket C — Qt int-flag OR → `int` `[arg-type]` / `[call-overload]` + +`Qt.AlignmentFlag(int)` has no `__or__` overload in stubs, so +`AlignRight | AlignVCenter` evaluates to `int`, but +`setAlignment(Alignment | AlignmentFlag)` refuses `int`. Same story for +`MatchFlag`, `DropAction`, `QIODevice.OpenModeFlag`. + +**Sites:** `gui/misc.py:14, 135`; `gui/instruments.py:56, 62, 68, 83, 97, +338`; `gui/base_instrument.py:315, 347`; `server/application.py:75, 357`; +`client/application.py:41`; `gui/__init__.py:9`. + +**Fix:** wrap with the Flags constructor: + +```python +from PyQt5.QtCore import Qt +flags = Qt.Alignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) +label.setAlignment(flags) +``` + +Project-wide decision: adopt this wrapping consistently, or leave the +ignores. Uniform wrapping reads slightly worse but removes ~15 ignores. + +### Bucket D — PyQt5 Signal `connect(Callable[..., bool])` `[arg-type]` + +`pyqtBoundSignal.connect` expects `Callable[..., None]`. Our callbacks that +return `bool` (startServer, SubClient.connect, Listener.connect) trigger +this. + +**Sites:** `gui/instruments.py:306`; `client/application.py:129`; +`server/application.py:719` (part of `[arg-type,attr-defined]`); +`server/core.py:668`; `server/application.py:63, 322` (lambda returning +`QAction | None`). + +**Fix:** wrap in a discarding lambda, e.g. +`thread.started.connect(lambda: server.startServer())`. Verbose; +arguably the ignore is fine. + +### Bucket E — `stationServer`/`stationServerThread` as `Optional` in `ServerGui` + +`ServerGui.__init__` sets `self.stationServer = None` / +`self.stationServerThread = None` before `startServer()` creates them. Every +downstream access in `startServer`, `_messageReceived`, `addInstrumentTab`, +`getServerIfRunning` is `union-attr`/`attr-defined`. + +**Sites:** `server/application.py:716, 717, 718, 719, 720, 721, 722, 725, +726, 727, 728, 731, 732, 733, 735, 738, 876`. + +**Fix:** don't initialize to `None`. Either +(a) construct `StationServer`/`QThread` eagerly in `__init__`, or +(b) introduce a typed container that is populated in `startServer` and +narrow via a helper method (e.g. `_require_server() -> StationServer`). +Option (b) removes 17 ignores in one class. + +### Bucket F — `self.cli: Optional[Client]` in `ProxyMixin`/`ProxyInstrumentModule` + +`ProxyMixin.__init__` takes `cli: Optional[Client] = None`. In +`ProxyInstrumentModule.__init__` there's a fallback `if cli is None: +self.cli = Client(...)`, but mypy doesn't track that post-init `self.cli` +is no longer `None`. + +**Sites:** `client/proxy.py:223, 257, 258, 428` (plus lines 276, 396, 420 +in adjacent buckets). + +**Fix:** split the declared types. `ProxyMixin.cli: Optional[Client]` +stays, but `ProxyInstrumentModule.cli: Client` can be redeclared (the +subclass guarantees initialization). Or store `self._cli: Client` and +provide a property that enforces the narrowing. + +### Bucket G — `QStandardItem | None` vs our `ItemBase` subclass (16 `attr-defined` + several `union-attr`) + +Items returned by `findItems`, `parent.child`, `itemFromIndex`, etc. are +typed `QStandardItem | None` by Qt stubs, but we always populate them with +`ItemBase`. Accessing `item.element`, `item.name`, `item.star`, `item.trash` +triggers `attr-defined`. + +**Sites:** `gui/instruments.py:270, 273, 365, 366, 465, 466, 469, 690, 691, +698`; `gui/base_instrument.py:894, 896, 897, 899, 900`; +`server/application.py:400, 401, 403, 415, 416`. + +**Fix:** `typing.cast(ItemBase, item)` at the boundary where we retrieve +from Qt, then all downstream access is clean. Removes ~20 ignores. + +### Bucket H — Already-documented structural issues + +Cross-references to earlier sections: + +- **Problem 1** (`itemsHide`/`itemsStar`/`itemsTrash` mutable-default + + wrong `Optional`): `base_instrument.py:261, 265, 267, 328, 330, 332`; + `gui/instruments.py:341`. +- **Problem 2** (`parent` union in `addItem`): `base_instrument.py:329, + 335, 336, 338, 341, 456, 479, 593, 605`. +- **Problem 3** (`Lock` vs `RLock`): `server/core.py:452, 455`. +- **Problem 4** (`isinstance(obj, tuple(list_var))`): `server/core.py:519, + 526`. + +### Bucket I — qcodes `TSubmodule` TypeVar constraint (3 `type-var`) + +qcodes's `InstrumentBase.add_submodule` has a TypeVar bound that rejects +our `ProxyInstrumentModule` and `ParameterManager` subclasses. + +**Sites:** `client/proxy.py:396, 420`; `params.py:190`. + +**Fix:** upstream qcodes would need to loosen the bound. Leave the +ignores; note in upstream bug tracker if desired. + +### Bucket J — `self.layout` attribute shadows `QWidget.layout()` method (1 combined) + +`ServerStatus.__init__` does `self.layout = QtWidgets.QVBoxLayout(self)`, +which mypy flags as overwriting the `layout()` method on QWidget (hence +`[assignment,method-assign]` at line 127 and `[attr-defined]` on every +subsequent `self.layout.addLayout/addWidget`). + +**Sites:** `server/application.py:127, 141, 144, 147`. + +**Fix:** rename to `self._layout` (consistent with convention elsewhere in +this codebase). Drops 4 ignores. + +### Bucket K — `pollingRates: Optional[Dict]` always-present-at-runtime + +Same pattern as `itemsHide`: `PollingWorker.pollingRates: Optional[Dict]`, +but methods dereference it unconditionally. + +**Sites:** `server/pollingWorker.py:40, 45, 49, 53, 56`. + +**Fix:** store as `Dict[str, int]` with `{}` default, or guard the top of +`run()` with `if self.pollingRates is None: return`. + +### Bucket L — `self.client: Client` reassigned to `None` on disconnect (1) + +`ClientStation.disconnect` sets `self.client = None`, but +`self.client: Client` was declared non-Optional. Fix: declare as +`Optional[Client]` and narrow at use sites, or don't null it out on +disconnect. + +**Site:** `client/proxy.py:912`. + +### Bucket M — `send(self.socket, ...)` / `recv(self.socket)` (2) + +`BaseClient.socket: Optional[zmq.Socket]`. Inside `ask()`, the `connected` +flag guarantees non-None but mypy doesn't know that. + +**Sites:** `client/core.py:96, 97`. + +**Fix:** early `assert self.socket is not None` at the start of `ask()`. + +### Bucket N — Misc small issues + +- `client/proxy.py:232` — `self.remove_parameter = MethodType(...)`: Python + lets us, mypy doesn't. `[method-assign]`. Leave it; it's an intentional + monkey-patch. +- `client/proxy.py:385` — `return globs[bp.name]`: `globs` is + `Dict[str, Any]`, but the function dict-lookup returns `object`. Fix + with `cast(Callable, globs[bp.name])`. +- `client/proxy.py:608` — `self.getParamDict(instrument=name, *args)`: + positional-after-keyword potential. `[misc]`. Low priority. +- `client/proxy.py:1027` — `file_path: str | None` passed to + `paramsToFile(str)`. Fix: narrow before calling. +- `blueprints.py:285, 289, 293` — `self.parameters.items()` on + `Optional[Dict]`. Same fix pattern as Bucket K. +- `blueprints.py:382` — `self.bp_type`: **real bug**, attribute doesn't + exist on `ParameterBroadcastBluePrint`. Flagged in the earlier summary. + Fix: remove the reference in `pprint`, or add the attribute. +- `serialize.py:285` — submodule iteration typed loosely; similar to + Bucket H Problem-4 pattern. +- `server/core.py:579, 582, 602, 608, 668` — broadcast socket and + `spec.args` Optionals (same Optional-narrow-at-use pattern). +- `params.py:96` (`getWorkingDirectory` `[no-untyped-def]`) — intentional; + annotating breaks the proxy `exec()` signature rendering (see earlier + note in this file). +- `params.py:193, 257, 258` — qcodes submodule typing mismatches; same + family as Bucket I. +- `server/application.py:565` (`[misc]`) — similar to proxy.py:608. +- `server/application.py:712` (`event.accept()` on `Optional`) — add + early-return for `event is None`. +- `server/application.py:846` (`setObject(bp)` with `bp` possibly None) — + change `setObject` signature to `Optional[...]` and guard inside. +- `gui/__init__.py:9` — `QFile.open(flags)` where flags is `int` from + enum OR (Bucket C). +- `gui/misc.py:217` — `DetachedTab(widget, name, parent=self)` where + `widget` is `Optional`. Narrow with assertion. +- `monitoring/listener.py:16` — `influxdb_client` import not found. This + is an optional dependency; `[import-not-found]` is correct — the + `try/except ImportError` handles runtime. + +## Priority if tackling + +1. **High value, low risk:** Buckets G (cast ItemBase, −20), J (rename + `self.layout`, −4), M (assert socket, −2). Plus Problems 1–4 already + spec'd. +2. **Medium value, some refactor:** Buckets E (require-helper for + stationServer, −17), F (declare `ProxyInstrumentModule.cli: Client`, + −4), B (narrow-once locals for widget returns, ~−70). +3. **Low value / project-style decisions:** Buckets A (event handler + Optional args), C (Flags constructor wrapping), D (wrap connect + callbacks). +4. **Will not go away:** Bucket I (qcodes upstream), the handful of + intentional ignores (`[method-assign]`, `[import-not-found]`, + `[no-untyped-def]`). + +Full elimination target: **208 → ~30 ignores** after all of (1) + (2) + +Problems 1–4 are applied. + +## Related PyQt5 stub workaround (cannot be cleaned up) + +`findItems(name, MatchFlag | MatchFlag, 0)` requires one more ignore that will +*not* go away: PyQt5's stubs declare `MatchFlag(int)` without an `__or__` +override, so `MatchFlag | MatchFlag` resolves to `int`, but `findItems` +expects `MatchFlags | MatchFlag`. Either leave the `# type: ignore[arg-type]` +or wrap flags explicitly: + +```python +from PyQt5.QtCore import Qt +flags = Qt.MatchFlags( + Qt.MatchFlag.MatchExactly | Qt.MatchFlag.MatchRecursive +) +items = self.findItems(smName, flags, 0) +``` + +Same pattern exists in `gui/instruments.py` and `server/application.py` around +`findItems` and `setAlignment` calls. Worth deciding on a project-wide approach. + +## Test gap before refactoring + +Nothing in `test/pytest/` directly exercises: + +- `InstrumentModelBase.addItem` / `insertItemTo` (covered only transitively + via `test_server_gui.py::test_opening_new_tab_generic_object`, and only for + an instrument with submodules). +- The `itemsHide` / `itemsStar` / `itemsTrash` filter branches (no test + passes these kwargs). +- `fillCollapsedDict` / `restoreCollapsedDict` (only fire on the filter + textbox signals, which no test triggers). + +Before doing the refactor, it is worth adding two small unit tests: + +1. Construct `InstrumentParameters` with `parameters-hide=["some_pattern"]` + and assert the matching parameter is not present in the model. +2. Create two `ModelParameters` instances with no `itemsStar` kwarg and + assert `m1.itemsStar is not m2.itemsStar` (guards the mutable-default fix). + +## Summary + +Two root causes produce roughly 10 `# type: ignore` comments. Fixing them: +- removes one latent bug (shared mutable default list), +- drops ignores in `base_instrument.py` and cascades cleanup into + `gui/instruments.py` (`fillCollapsedDict` callers) and any caller of + `insertItemTo`, +- needs two small unit tests added first to protect the filter-branch and + mutable-default behavior that currently has no coverage. diff --git a/docs/adr/0001-duck-typed-parameter-manager-types.md b/docs/adr/0001-duck-typed-parameter-manager-types.md new file mode 100644 index 0000000..4b87424 --- /dev/null +++ b/docs/adr/0001-duck-typed-parameter-manager-types.md @@ -0,0 +1,20 @@ +--- +status: accepted +date: 2026-09-16 +--- + +# Parameter Manager Types are structural (duck-typed), never a stored membership + +A Type in the Parameter Manager is a named shape: a set of relative parameter paths with units, plus Nested Types at named submodules. A submodule is an Instance of a Type purely because it carries every path of that shape with the declared unit; nothing records "q01 is a qubit". Membership is recomputed on demand and never persisted. + +## Considered options + +- **Explicit registry**: tag submodules with a Type and enforce structure on tagged ones. Rejected: it adds membership state to persist and migrate, and it makes "parameter removed from the Type but still on the Instance" a special case instead of the natural outcome (the row is simply untyped now). +- **Structural, computed** (chosen): matches how the lab thinks about it ("everything shaped like a qubit is a qubit"), needs no membership state, and lets one submodule match several Types (innermost, then largest, claims the tint). + +## Consequences + +- Deleting a required parameter silently makes a submodule stop matching; that is accepted. Deleting a parameter that is a Lock Target is the case that gets a GUI confirmation, not this one. +- An empty Type matches nothing, so a freshly created Type does not claim the tree. +- Matching walks the tree on every query. Trees are hundreds of parameters; no caching in the first version. +- The `_globals` submodule is excluded from matching at any depth. diff --git a/docs/adr/0002-pull-based-locks.md b/docs/adr/0002-pull-based-locks.md new file mode 100644 index 0000000..56ef9dd --- /dev/null +++ b/docs/adr/0002-pull-based-locks.md @@ -0,0 +1,21 @@ +--- +status: accepted +date: 2026-09-16 +--- + +# Parameter Locks pull on `get`; nothing is ever pushed into a Follower + +A locked Follower answers `get` by asking its Target and returning that value; `set` on a locked Follower raises. The Target knows nothing about its Followers, no value is ever copied into a Follower, and "who follows X" is computed by scanning when asked. The behaviour lives in a `Parameter` subclass (`ManagedParameter`) so the server's call path, the instrument mutex and the existing broadcasts are untouched: the server resolves the dotted path and calls the parameter exactly as it does today. + +## Considered options + +- **Push on set**: a Target's `set` rewrites every Follower and each rewrite needs its own broadcast. Rejected: it fans one request into many writes, requires the instrument to report side effects to the server for every value change, and forces a dedup rule so Listeners do not log twice. +- **Pull on get** (chosen): minimal change, the broadcast path is unchanged for values, cycle checking is just a walk up the Target chain, and each hop of a chain is decided by that hop's own locked/unlocked state at read time. + +## Consequences + +- A Follower keeps its own underlying value while locked. Unlocking exposes that own value again (deliberate: "unlock and see its own value" is the point). Profiles store the own value plus the Lock. +- QCoDeS reads the cache, not `get`, for snapshots with `update=False`. `ManagedParameter` therefore reports the Target's value in its snapshot while locked, and the profile writer reads the own value explicitly. Otherwise measurement metadata would record stale values. +- GUIs repaint Followers when they receive a `parameter-update` for the Target; the Parameter Manager emits nothing for values. +- Deleting a Target removes the Locks pointing at it; their Followers become plain parameters. +- Targets are restricted to parameters inside the same Parameter Manager. diff --git a/docs/adr/0003-broadcaster-contract.md b/docs/adr/0003-broadcaster-contract.md new file mode 100644 index 0000000..c9266ac --- /dev/null +++ b/docs/adr/0003-broadcaster-contract.md @@ -0,0 +1,23 @@ +--- +status: accepted +date: 2026-09-16 +--- + +# Instruments emit their own Broadcasts through an opt-in `Broadcaster` contract + +The Server only broadcasts what it can see: parameter sets it executed, and calls whose method name is literally `add_parameter` or `remove_parameter`. Structural changes inside an instrument (a Type edited, a Lock declared, parameters created as a side effect of `add_instance`) were invisible to other clients. We add a small mixin, `Broadcaster`, with `add_broadcast_sink`, `remove_broadcast_sink` and `broadcast(blueprint)`. When an instrument joins the Station (creation over the wire, or loading from config at startup) the Server checks `hasattr(instrument, "add_broadcast_sink")` and registers its own broadcast function as a sink. Instruments without the mixin are registered exactly as before. + +## Considered options + +- **Hard-code more method names in the Server**, as `add_parameter` is today. Rejected: the Server accumulates one instrument's API. +- **Instrument declares a method-name to action mapping** that the Server consults after each call. Rejected: passive and coarse; cannot express "one message per created parameter" and still couples the Server to method names. +- **Server diffs state after each call.** Rejected: heavy, cannot express Type changes. +- **GUI polling.** Rejected: latency and traffic for nothing. +- **Opt-in emitter contract** (chosen): generic, three lines in the Server, no base-class change, usable by any future Virtual Instrument, and standalone use of the instrument (no sinks) is a no-op. + +## Consequences + +- Messages use the existing `ParameterBroadcastBluePrint`, same socket, same topic (instrument name), same wire format. Existing subscribers parse them unchanged. +- The Parameter Manager emits `pm-lock-update` (`PMLockBluePrint` payload), `pm-type-update` (`PMTypeBluePrint` payload), and re-emits `parameter-creation` / `parameter-deletion` for parameters it creates or removes as side effects. Direct `add_parameter` / `remove_parameter` calls keep being announced by the Server, so nothing is announced twice. +- `broadcast` runs on the thread that called the instrument method, which for a client request is the worker thread holding the instrument mutex: the same place the Server's own broadcasts already run. +- The `_instrument_locks` code in the Server is not renamed or altered; a comment notes that prose calls it the "instrument mutex". diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..bd92fb1 --- /dev/null +++ b/opencode.json @@ -0,0 +1,420 @@ +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "coder": { + "description": "Implements one plan task, runs its tests, commits. Role: .agents/roles/coder.md", + "mode": "primary", + "model": "lumen/glm-5.3-flash", + "prompt": "{file:./.agents/roles/coder.md}", + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": "allow", + "bash": { + "*": "ask", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git rev-parse*": "allow", + "ls*": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "grep *": "allow", + "rg *": "allow", + "uv run pytest*": "allow", + "orca orchestration check*": "allow", + "*/orca orchestration check*": "allow", + "orca orchestration send*": "allow", + "*/orca orchestration send*": "allow", + "orca orchestration ask*": "allow", + "*/orca orchestration ask*": "allow", + "git add *": "allow", + "git commit -m*": "allow", + "git push*": "deny", + "git rebase*": "deny", + "git reset*": "deny", + "git commit --amend*": "deny", + "git commit * --amend*": "deny", + "git stash*": "deny", + "git checkout*": "deny", + "git switch*": "deny", + "git branch -d*": "deny", + "git branch -D*": "deny", + "git branch --delete*": "deny", + "git clean*": "deny", + "git add -A*": "deny", + "git add .*": "deny", + "git add --all*": "deny" + }, + "webfetch": "ask", + "external_directory": "ask" + } + }, + "reviewer-deepseek": { + "description": "General code review of one plan task's commits. Read-only. Role: .agents/roles/reviewer.md", + "mode": "primary", + "model": "lumen/deepseek-v4-flash", + "prompt": "{file:./.agents/roles/reviewer.md}", + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": { + "*": "deny", + "orchestration/**": "allow" + }, + "bash": { + "*": "ask", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git rev-parse*": "allow", + "ls*": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "grep *": "allow", + "rg *": "allow", + "uv run pytest*": "allow", + "orca orchestration check*": "allow", + "*/orca orchestration check*": "allow", + "orca orchestration send*": "allow", + "*/orca orchestration send*": "allow", + "orca orchestration ask*": "allow", + "*/orca orchestration ask*": "allow", + "mkdir -p orchestration/*": "allow", + "mkdir -p */orchestration/*": "allow", + "git push*": "deny", + "git rebase*": "deny", + "git reset*": "deny", + "git commit --amend*": "deny", + "git commit * --amend*": "deny", + "git stash*": "deny", + "git checkout*": "deny", + "git switch*": "deny", + "git branch -d*": "deny", + "git branch -D*": "deny", + "git branch --delete*": "deny", + "git clean*": "deny", + "git add -A*": "deny", + "git add .*": "deny", + "git add --all*": "deny", + "git add*": "deny", + "git commit*": "deny" + }, + "webfetch": "ask", + "external_directory": "ask" + } + }, + "reviewer-qwen": { + "description": "General code review of one plan task's commits. Read-only. Role: .agents/roles/reviewer.md", + "mode": "primary", + "model": "lumen/qwen3-coder-next", + "prompt": "{file:./.agents/roles/reviewer.md}", + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": { + "*": "deny", + "orchestration/**": "allow" + }, + "bash": { + "*": "ask", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git rev-parse*": "allow", + "ls*": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "grep *": "allow", + "rg *": "allow", + "uv run pytest*": "allow", + "orca orchestration check*": "allow", + "*/orca orchestration check*": "allow", + "orca orchestration send*": "allow", + "*/orca orchestration send*": "allow", + "orca orchestration ask*": "allow", + "*/orca orchestration ask*": "allow", + "mkdir -p orchestration/*": "allow", + "mkdir -p */orchestration/*": "allow", + "git push*": "deny", + "git rebase*": "deny", + "git reset*": "deny", + "git commit --amend*": "deny", + "git commit * --amend*": "deny", + "git stash*": "deny", + "git checkout*": "deny", + "git switch*": "deny", + "git branch -d*": "deny", + "git branch -D*": "deny", + "git branch --delete*": "deny", + "git clean*": "deny", + "git add -A*": "deny", + "git add .*": "deny", + "git add --all*": "deny", + "git add*": "deny", + "git commit*": "deny" + }, + "webfetch": "ask", + "external_directory": "ask" + } + }, + "test-reviewer-deepseek": { + "description": "Reviews whether the tests prove the task and what is untested. Read-only. Role: .agents/roles/test-reviewer.md", + "mode": "primary", + "model": "lumen/deepseek-v4-flash", + "prompt": "{file:./.agents/roles/test-reviewer.md}", + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": { + "*": "deny", + "orchestration/**": "allow" + }, + "bash": { + "*": "ask", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git rev-parse*": "allow", + "ls*": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "grep *": "allow", + "rg *": "allow", + "uv run pytest*": "allow", + "orca orchestration check*": "allow", + "*/orca orchestration check*": "allow", + "orca orchestration send*": "allow", + "*/orca orchestration send*": "allow", + "orca orchestration ask*": "allow", + "*/orca orchestration ask*": "allow", + "mkdir -p orchestration/*": "allow", + "mkdir -p */orchestration/*": "allow", + "git push*": "deny", + "git rebase*": "deny", + "git reset*": "deny", + "git commit --amend*": "deny", + "git commit * --amend*": "deny", + "git stash*": "deny", + "git checkout*": "deny", + "git switch*": "deny", + "git branch -d*": "deny", + "git branch -D*": "deny", + "git branch --delete*": "deny", + "git clean*": "deny", + "git add -A*": "deny", + "git add .*": "deny", + "git add --all*": "deny", + "git add*": "deny", + "git commit*": "deny" + }, + "webfetch": "ask", + "external_directory": "ask" + } + }, + "test-reviewer-qwen": { + "description": "Reviews whether the tests prove the task and what is untested. Read-only. Role: .agents/roles/test-reviewer.md", + "mode": "primary", + "model": "lumen/qwen3-coder-next", + "prompt": "{file:./.agents/roles/test-reviewer.md}", + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": { + "*": "deny", + "orchestration/**": "allow" + }, + "bash": { + "*": "ask", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git rev-parse*": "allow", + "ls*": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "grep *": "allow", + "rg *": "allow", + "uv run pytest*": "allow", + "orca orchestration check*": "allow", + "*/orca orchestration check*": "allow", + "orca orchestration send*": "allow", + "*/orca orchestration send*": "allow", + "orca orchestration ask*": "allow", + "*/orca orchestration ask*": "allow", + "mkdir -p orchestration/*": "allow", + "mkdir -p */orchestration/*": "allow", + "git push*": "deny", + "git rebase*": "deny", + "git reset*": "deny", + "git commit --amend*": "deny", + "git commit * --amend*": "deny", + "git stash*": "deny", + "git checkout*": "deny", + "git switch*": "deny", + "git branch -d*": "deny", + "git branch -D*": "deny", + "git branch --delete*": "deny", + "git clean*": "deny", + "git add -A*": "deny", + "git add .*": "deny", + "git add --all*": "deny", + "git add*": "deny", + "git commit*": "deny" + }, + "webfetch": "ask", + "external_directory": "ask" + } + }, + "plan-checker-deepseek": { + "description": "Checks commits against the plan, glossary, decisions and ADRs. Read-only. Role: .agents/roles/plan-checker.md", + "mode": "primary", + "model": "lumen/deepseek-v4-flash", + "prompt": "{file:./.agents/roles/plan-checker.md}", + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": { + "*": "deny", + "orchestration/**": "allow" + }, + "bash": { + "*": "ask", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git rev-parse*": "allow", + "ls*": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "grep *": "allow", + "rg *": "allow", + "uv run pytest*": "allow", + "orca orchestration check*": "allow", + "*/orca orchestration check*": "allow", + "orca orchestration send*": "allow", + "*/orca orchestration send*": "allow", + "orca orchestration ask*": "allow", + "*/orca orchestration ask*": "allow", + "mkdir -p orchestration/*": "allow", + "mkdir -p */orchestration/*": "allow", + "git push*": "deny", + "git rebase*": "deny", + "git reset*": "deny", + "git commit --amend*": "deny", + "git commit * --amend*": "deny", + "git stash*": "deny", + "git checkout*": "deny", + "git switch*": "deny", + "git branch -d*": "deny", + "git branch -D*": "deny", + "git branch --delete*": "deny", + "git clean*": "deny", + "git add -A*": "deny", + "git add .*": "deny", + "git add --all*": "deny", + "git add*": "deny", + "git commit*": "deny" + }, + "webfetch": "ask", + "external_directory": "ask" + } + }, + "plan-checker-qwen": { + "description": "Checks commits against the plan, glossary, decisions and ADRs. Read-only. Role: .agents/roles/plan-checker.md", + "mode": "primary", + "model": "lumen/qwen3-coder-next", + "prompt": "{file:./.agents/roles/plan-checker.md}", + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "edit": { + "*": "deny", + "orchestration/**": "allow" + }, + "bash": { + "*": "ask", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git rev-parse*": "allow", + "ls*": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "grep *": "allow", + "rg *": "allow", + "uv run pytest*": "allow", + "orca orchestration check*": "allow", + "*/orca orchestration check*": "allow", + "orca orchestration send*": "allow", + "*/orca orchestration send*": "allow", + "orca orchestration ask*": "allow", + "*/orca orchestration ask*": "allow", + "mkdir -p orchestration/*": "allow", + "mkdir -p */orchestration/*": "allow", + "git push*": "deny", + "git rebase*": "deny", + "git reset*": "deny", + "git commit --amend*": "deny", + "git commit * --amend*": "deny", + "git stash*": "deny", + "git checkout*": "deny", + "git switch*": "deny", + "git branch -d*": "deny", + "git branch -D*": "deny", + "git branch --delete*": "deny", + "git clean*": "deny", + "git add -A*": "deny", + "git add .*": "deny", + "git add --all*": "deny", + "git add*": "deny", + "git commit*": "deny" + }, + "webfetch": "ask", + "external_directory": "ask" + } + } + } +} From 46e34cde26e3a31b771526dd760fe5308fcbebe2 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 16:19:51 -0500 Subject: [PATCH 002/107] 0.1: split ParameterGroup out of ParameterManager --- src/instrumentserver/params.py | 220 ++++++++++++++++-------------- test/pytest/test_param_manager.py | 41 +++++- 2 files changed, 159 insertions(+), 102 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 2d19ce8..ae05be5 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -56,111 +56,31 @@ def paramTypeFromName(name: str) -> Union[ParameterTypes, None]: return None -class ParameterManager(InstrumentBase): +class ParameterGroup(InstrumentBase): """ - A virtual instrument that acts as a manager for a collection of - arbitrary parameters and groups of parameters. - - Allows extra-easy on-the-fly addition/removal of new parameters. - - For the parameter manager to recognize other profiles in disk, - the profile filename needs to start with 'parameter_manager-' - and end with '.json' with the name of the profile in the middle. - For example, 'parameter_manager-qubit1.json' represents the profile qubit1 + A Parameter Group: a plain container of parameters and nested Parameter + Groups inside a Parameter Manager. + + It holds parameters and nested Parameter Groups and offers the tree + helpers (dotted-path add/remove/get/set, listing, tree building), but + has no file, profile, Type or Lock logic of its own. Every submodule + of a Parameter Manager is a Parameter Group; only the root is the + Parameter Manager, which extends the Parameter Group with those + responsibilities. """ - # TODO: method to instantiate entirely from paramDict - - def __init__(self, name: str) -> None: - super().__init__(name) - - self._workingDirectory = Path(os.getcwd()) - - #: default location and name of the parameters save file. - self.selectedProfile = self.fullProfileName(self.name) - self.profiles: List[str] = [] - self.refresh_profiles() - - self.fromFile() - - @property - def workingDirectory(self) -> Path: - return self._workingDirectory - - @workingDirectory.setter - def workingDirectory(self, path: Union[str, Path]) -> None: - self._workingDirectory = Path(path) - self.refresh_profiles() - - def getWorkingDirectory(self): # type: ignore[no-untyped-def] - return self.workingDirectory - - @staticmethod - def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": - """Create a new ParameterManager instance from a paramDict. - - :param paramDict: The paramDict object. - :param name: Name of the instrument in the paramDict (each entry in the - paramDict starts with .[...]). - :returns: New ParameterManager instance. - """ - raise NotImplementedError - - @staticmethod - def cleanProfileName(name: str) -> str: - """ - When passed the full file name of a parameter_manager profile, return only the middle - string representing the profile's name. - """ - return name.replace("parameter_manager-", "").replace(".json", "") - - @staticmethod - def fullProfileName(name: str) -> str: - """ - Adds 'parameter_manager-' to the beginning of `name` and adds '.json' at the end. - """ - - if not name.startswith("parameter_manager-"): - name = "parameter_manager-" + name - if not name.endswith(".json"): - name += ".json" - return name - @classmethod - def _to_tree(cls, pm: "ParameterManager") -> Dict: + def _to_tree(cls, pm: "ParameterGroup") -> Dict: ret: dict[str, Any] = {} for smn, sm in pm.submodules.items(): - assert isinstance(sm, ParameterManager) + assert isinstance(sm, ParameterGroup) ret[smn] = cls._to_tree(sm) for pn, p in pm.parameters.items(): ret[pn] = p return ret - @classmethod - def does_profile_exist(cls, profiles: List[str], target: str) -> bool: - found = False - for profile in profiles: - if target in profile: - found = True - break - return found - - def refresh_profiles(self) -> List[str]: - """ - Goes into the working directory and updates the list of profiles. - - :return: List of profiles in the working directory - """ - profiles = [] - for filename in os.listdir(self.workingDirectory): - if filename.startswith("parameter_manager") and filename.endswith(".json"): - profiles.append(filename) - - self.profiles = profiles - return profiles - def to_tree(self) -> Dict: - return ParameterManager._to_tree(self) + return ParameterGroup._to_tree(self) def _get_param(self, param_name: str) -> ParameterBase: parent = self._get_parent(param_name) @@ -172,7 +92,7 @@ def _get_param(self, param_name: str) -> ParameterBase: def _get_parent( self, param_name: str, create_parent: bool = False - ) -> "ParameterManager": + ) -> "ParameterGroup": split_names = param_name.split(".") parent = self @@ -186,7 +106,7 @@ def _get_parent( ) if n not in parent.submodules: if create_parent: - parent.add_submodule(n, ParameterManager(n)) # type: ignore[type-var] + parent.add_submodule(n, ParameterGroup(n)) # type: ignore[type-var] else: raise ValueError(f"{n} does not exist.") parent = parent.submodules[n] # type: ignore[assignment] @@ -261,12 +181,6 @@ def purge(parent: InstrumentBase) -> None: purge(self) - def remove_all_parameters(self) -> None: - """Remove all parameters from the instrument.""" - for param in self.list(): - self.remove_parameter(param, cleanup=False) - self.remove_empty_submodules() - def parameter(self, name: str) -> ParameterBase: """Get a parameter object from the manager. @@ -290,6 +204,110 @@ def tolist(x: Dict[str, Any]) -> List[str]: return tolist(tree) + +class ParameterManager(ParameterGroup): + """ + A virtual instrument that acts as a manager for a collection of + arbitrary parameters and groups of parameters. + + Allows extra-easy on-the-fly addition/removal of new parameters. + + The Parameter Manager is the root of the parameter tree. It extends the + Parameter Group with file, profile, and (later) Type and Lock logic; + its submodules are plain Parameter Groups. + + For the parameter manager to recognize other profiles in disk, + the profile filename needs to start with 'parameter_manager-' + and end with '.json' with the name of the profile in the middle. + For example, 'parameter_manager-qubit1.json' represents the profile qubit1 + """ + + # TODO: method to instantiate entirely from paramDict + + def __init__(self, name: str) -> None: + super().__init__(name) + + self._workingDirectory = Path(os.getcwd()) + + #: default location and name of the parameters save file. + self.selectedProfile = self.fullProfileName(self.name) + self.profiles: List[str] = [] + self.refresh_profiles() + + self.fromFile() + + @property + def workingDirectory(self) -> Path: + return self._workingDirectory + + @workingDirectory.setter + def workingDirectory(self, path: Union[str, Path]) -> None: + self._workingDirectory = Path(path) + self.refresh_profiles() + + def getWorkingDirectory(self): # type: ignore[no-untyped-def] + return self.workingDirectory + + @staticmethod + def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": + """Create a new ParameterManager instance from a paramDict. + + :param paramDict: The paramDict object. + :param name: Name of the instrument in the paramDict (each entry in the + paramDict starts with .[...]). + :returns: New ParameterManager instance. + """ + raise NotImplementedError + + @staticmethod + def cleanProfileName(name: str) -> str: + """ + When passed the full file name of a parameter_manager profile, return only the middle + string representing the profile's name. + """ + return name.replace("parameter_manager-", "").replace(".json", "") + + @staticmethod + def fullProfileName(name: str) -> str: + """ + Adds 'parameter_manager-' to the beginning of `name` and adds '.json' at the end. + """ + + if not name.startswith("parameter_manager-"): + name = "parameter_manager-" + name + if not name.endswith(".json"): + name += ".json" + return name + + @classmethod + def does_profile_exist(cls, profiles: List[str], target: str) -> bool: + found = False + for profile in profiles: + if target in profile: + found = True + break + return found + + def refresh_profiles(self) -> List[str]: + """ + Goes into the working directory and updates the list of profiles. + + :return: List of profiles in the working directory + """ + profiles = [] + for filename in os.listdir(self.workingDirectory): + if filename.startswith("parameter_manager") and filename.endswith(".json"): + profiles.append(filename) + + self.profiles = profiles + return profiles + + def remove_all_parameters(self) -> None: + """Remove all parameters from the instrument.""" + for param in self.list(): + self.remove_parameter(param, cleanup=False) + self.remove_empty_submodules() + def fromFile( self, filePath: str | None = None, diff --git a/test/pytest/test_param_manager.py b/test/pytest/test_param_manager.py index 67b168b..f0d3a15 100644 --- a/test/pytest/test_param_manager.py +++ b/test/pytest/test_param_manager.py @@ -1,6 +1,6 @@ import json -from instrumentserver.params import ParameterManager +from instrumentserver.params import ParameterGroup, ParameterManager def prep_param_manager(params, template=1): @@ -215,3 +215,42 @@ def test_selectedProfile_only_changing_when_correct_name(tmp_path): new_path = names_path.replace(tmp_path.joinpath("parameter_manager-names.json")) params.fromFile(new_path) assert params.selectedProfile == "parameter_manager-names.json" + + +def test_submodules_are_groups(caplog): + params = ParameterManager(name="params") + # the root itself may warn about its own missing profile file; + # only warnings from creating the submodule are of interest here + caplog.clear() + + params.add_parameter(name="q01.IF", initial_value=1e9, unit="Hz") + params.add_parameter(name="q01.readout.power", initial_value=-10, unit="dBm") + + assert isinstance(params.q01, ParameterGroup) + assert not isinstance(params.q01, ParameterManager) + assert isinstance(params.q01.readout, ParameterGroup) + assert not isinstance(params.q01.readout, ParameterManager) + + # creating a submodule no longer lists the working directory or + # tries to load a parameter file for it + assert "parameter file not found" not in caplog.text + + +def test_submodule_does_not_load_parameter_file(tmp_path, monkeypatch): + """Submodules are Parameter Groups with no file or profile logic of + their own: a parameter_manager-q01.json file in the working directory + is not loaded into the q01 submodule.""" + monkeypatch.chdir(tmp_path) + profile = tmp_path / "parameter_manager-q01.json" + profile.write_text( + json.dumps({"q01.file_param": {"value": 999, "unit": "V"}}) + ) + + params = ParameterManager(name="params") + params.add_parameter(name="q01.my_param", initial_value=1, unit="M") + + assert isinstance(params.q01, ParameterGroup) + assert not isinstance(params.q01, ParameterManager) + assert not params.q01.has_param("file_param") + assert "q01.file_param" not in params.list() + assert params.q01.my_param() == 1 From c753ae9bb6bf1cc1c4276d27164ae32a56e09092 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 16:42:15 -0500 Subject: [PATCH 003/107] 0.1: orchestration record Co-Authored-By: Claude Fable 5.1 --- PLAN_parameter_manager_redesign.md | 2 +- orchestration/0.1/decisions.md | 56 +++++++++++++++++++ orchestration/0.1/round-0/fix-list.md | 7 +++ .../0.1/round-0/plan-checker-deepseek.md | 38 +++++++++++++ .../0.1/round-0/plan-checker-qwen.md | 22 ++++++++ .../0.1/round-0/reviewer-deepseek.md | 20 +++++++ orchestration/0.1/round-0/reviewer-qwen.md | 26 +++++++++ .../0.1/round-0/test-reviewer-deepseek.md | 25 +++++++++ .../0.1/round-0/test-reviewer-qwen.md | 20 +++++++ orchestration/RUNS.md | 20 +++++++ 10 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 orchestration/0.1/decisions.md create mode 100644 orchestration/0.1/round-0/fix-list.md create mode 100644 orchestration/0.1/round-0/plan-checker-deepseek.md create mode 100644 orchestration/0.1/round-0/plan-checker-qwen.md create mode 100644 orchestration/0.1/round-0/reviewer-deepseek.md create mode 100644 orchestration/0.1/round-0/reviewer-qwen.md create mode 100644 orchestration/0.1/round-0/test-reviewer-deepseek.md create mode 100644 orchestration/0.1/round-0/test-reviewer-qwen.md create mode 100644 orchestration/RUNS.md diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 7d24194..4c83526 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -383,7 +383,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 0 — Foundations -- [ ] **0.1 `ParameterGroup` split.** In `params.py` create `ParameterGroup(InstrumentBase)` +- [x] **0.1 `ParameterGroup` split.** In `params.py` create `ParameterGroup(InstrumentBase)` holding parameters and nested groups with the tree helpers moved from `ParameterManager` (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`/`_to_tree`, `list`, `remove_empty_submodules`, the dotted `add_parameter`/`remove_parameter`/`get`/`set`). diff --git a/orchestration/0.1/decisions.md b/orchestration/0.1/decisions.md new file mode 100644 index 0000000..57df3bd --- /dev/null +++ b/orchestration/0.1/decisions.md @@ -0,0 +1,56 @@ +# 0.1 `ParameterGroup` split — decisions log + +Run: run_caa796369a9e. Branch: marcosfrenkel/new-param-manager. Base commit: 447c7f71542e443410684849084ae230cbc8ecfc. + +## Workers + +| agent id | terminal handle | current dispatch id | +|---|---|---| +| coder | term_4a013f23-1b48-4ca5-a78c-024c66cbf3f3 | ctx_c859551be4f9 (task_708c2eb80a84, first implementation) | +| reviewer-deepseek | term_c67207a2-4d71-42e7-b734-6c82f72661c1 | ctx_5e3ec812a3f5 (task_fd8922c3a974, round 0) | +| reviewer-qwen | term_551d82c0-fbb4-4888-b672-ec27de56b683 | ctx_738d54cd1961 (task_8d4fe0e3d9c7, round 0) | +| test-reviewer-deepseek | term_58b50438-41e6-43cf-a932-890e80f0e6cb | ctx_160f0a68a0ac (task_01e6109d0466, round 0) | +| test-reviewer-qwen | term_d420c0c1-f7f1-4eb4-9d7c-5aae16bcaac1 | ctx_a5219e6abadc (task_9f3e6c1bde9e, round 0) | +| plan-checker-deepseek | term_8611173c-bf5f-4825-9376-50bfbaa57911 | ctx_c1ac9fe459bd (task_ade2afeb0a58, round 0) | +| plan-checker-qwen | term_080e9cf8-55a8-4763-8e05-7c4fc4a6a44e | ctx_27057ebeb71c (task_a0ab54b9de98, round 0) | + +## Log + +- 21:08 Run run_caa796369a9e created. Checkbox 0.1 set to [~]. Base 447c7f7. +- 21:10 Coder dispatched for first implementation (task_708c2eb80a84 / ctx_c859551be4f9). +- 21:13 Permission: coder asked to run `uv run python -c "import qcodes, inspect; ... print(inspect.getsource(InstrumentBase.add_submodule))"`. Allowed once: read-only introspection of an installed library, no writes, no network. +- 21:16 Permission: coder asked to run `uv run python -c "... inspect.getsource(InstrumentBase.__init__) ..."`. Allowed once: read-only introspection. +- 21:19 Permission: coder asked to run `git status && git log --oneline -3 && git branch --show-current`. Allowed once: read-only git, chained with && so the allowlist did not match. +- 21:20 Coder worker_done (succeeded). Commit 46e34cd "0.1: split ParameterGroup out of ParameterManager", files: src/instrumentserver/params.py, test/pytest/test_param_manager.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit. Coder retained. +- 21:22 Orchestrator test run after first implementation: `uv run pytest test/pytest/test_param_manager.py` -> 12 passed in 5.17s; `uv run pytest` -> 161 passed, 4 warnings in 59.62s. +- Coder noted out of scope (not fixed, per plan rule 6): 4 PytestUnknownMarkWarning for unregistered 'integration' mark in test_apps.py; dead local `full_name` in `_get_parent`; the `_newOrDeleteParameterDetection` KeyError is task 0.4. +- 21:26 Six reviewers dispatched for round 0 (target 447c7f7..46e34cd). +- 21:28 plan-checker-qwen worker_done (succeeded, approve, 0 findings). Retained. +- 16:23 reviewer-qwen worker_done (succeeded, approve, 0 must/should-fix). Retained. +- 21:34 Permission: test-reviewer-deepseek asked to run `lsof -nP -iTCP:5555 -sTCP:LISTEN; lsof -nP -iTCP:5599 -sTCP:LISTEN` (port check before running tests). Allowed once: read-only. +- 21:34 Permission: test-reviewer-qwen asked to run `cd && orca orchestration send ... --type worker_done ...` (its own worker_done, the `cd &&` prefix broke the allowlist). Allowed once. +- 21:37 test-reviewer-qwen worker_done (succeeded, approve with 1 should-fix). A second duplicate worker_done was rejected by Orca (capability revoked after the first settled); no action needed. Retained. +- 21:38 Permission: reviewer-deepseek asked to run `cd && sed -n '180,190p' src/instrumentserver/serialize.py`. Allowed once: read-only. +- 21:44 Permission: reviewer-deepseek asked to run `git worktree add /tmp/param_base_base_check_... 447c7f7`. REJECTED: reviewers may not run state-changing git commands, and it writes outside the repo. Told it to use `git show :` instead. +- 16:30 plan-checker-deepseek worker_done (succeeded, approve, 0 must/should-fix, 1 nit). Retained. +- 16:30 test-reviewer-deepseek worker_done (succeeded, approve, 2 nits; its full-suite run hit a port-5555 collision from concurrent reviewer test runs, green in isolation). Retained. +- 16:38 reviewer-deepseek went idle (activity done, liveness live) with no report and no worker_done. Nudged in its terminal to write the report and send worker_done. +- 16:40 reviewer-deepseek worker_done after nudge (succeeded, approve, 0 must/should-fix, 1 nit). Retained. All six round-0 reports present. + +## Round 0 merge (six reports, all `approve`) + +- test-reviewer-qwen F1 (should-fix): "test_submodule_does_not_load_parameter_file does not assert the submodule is a ParameterGroup". One model only; orchestrator read the test: line 257 already has `assert isinstance(params.q01, ParameterGroup)`. DROPPED: not confirmed, factually wrong. +- test-reviewer-deepseek F1 + reviewer-deepseek F1 (both nit): the "no longer lists the working directory" half of the acceptance is proven only by construction (ParameterGroup has no refresh_profiles), not by a direct assertion. Two roles, both rated nit; the sibling test proves no file logic runs on a submodule. Not sent: nit. +- test-reviewer-deepseek F2 (nit): run `test_submodules_are_groups` under `tmp_path` so the caplog assertion cannot go vacuous if a parameter_manager-q01.json sits in cwd. Not sent: nit. Reasonable hardening; can ride along with a later task touching that file. +- test-reviewer-qwen F2 (nit): add a comment on ParameterGroup explaining it has no __init__. Not sent: nit. +- plan-checker-deepseek N1 (nit): pre-existing dead `full_name` local in `_get_parent`. Not sent: nit and out of scope (plan rule 6). +- plan-checker-qwen F1: placeholder "no issues". Nothing to do. +- Full-suite noise seen by reviewers: test-reviewer-deepseek got 1 failed (port 5555 in use), reviewer-deepseek got 1 setup error in the param_manager fixture, plan-checker-deepseek got a PyQt crash on first run. All three ran `uv run pytest` concurrently against fixed ports; each passed in isolation or on rerun, and the orchestrator's own run was 161 passed. Judged environment noise from parallel reviewer test runs, not a code defect. Process note for RUNS.md: tell reviewers to run only the named test file, or stagger full-suite runs. +- Fix list: EMPTY. Task goes to finish. + +## Finish + +- All seven workers released (Orca kept the externally created terminals: state retained, processAction none) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_caa796369a9e: 0 rows. +- Checkbox 0.1 set to [x]. + +**Summary.** Outcome: done. Commits: `46e34cd 0.1: split ParameterGroup out of ParameterManager`. Fix rounds used: 0. Tests (orchestrator run): `uv run pytest test/pytest/test_param_manager.py` -> 12 passed in 5.17s; `uv run pytest` -> 161 passed, 4 warnings in 59.62s. diff --git a/orchestration/0.1/round-0/fix-list.md b/orchestration/0.1/round-0/fix-list.md new file mode 100644 index 0000000..3a03c8d --- /dev/null +++ b/orchestration/0.1/round-0/fix-list.md @@ -0,0 +1,7 @@ +# 0.1 — round 0 — fix list + +Empty. All six reviewers returned `approve`. No must-fix findings. The single should-fix +(test-reviewer-qwen F1) was raised by one model only and was not confirmed by the +orchestrator: `test_submodule_does_not_load_parameter_file` already asserts +`isinstance(params.q01, ParameterGroup)` (test/pytest/test_param_manager.py:257). +Remaining findings are nits and were not sent. See decisions.md. diff --git a/orchestration/0.1/round-0/plan-checker-deepseek.md b/orchestration/0.1/round-0/plan-checker-deepseek.md new file mode 100644 index 0000000..e4695da --- /dev/null +++ b/orchestration/0.1/round-0/plan-checker-deepseek.md @@ -0,0 +1,38 @@ +# 0.1 — plan-checker-deepseek — round 0 + +Verdict: approve + +Reviewed commit `46e34cd` ("0.1: split ParameterGroup out of ParameterManager") in `447c7f7..46e34cd` against PLAN_parameter_manager_redesign.md, CONTEXT.md, ADR-0001/0002/0003. The commit touches exactly `src/instrumentserver/params.py` and `test/pytest/test_param_manager.py` — nothing out of scope. + +## Findings + +No must-fix or should-fix findings. + +### N1 — nit +- Where: src/instrumentserver/params.py:99,102 +- What: `_get_parent` still accumulates `full_name` (lines 99, 102) but never uses it. +- Why: Pre-existing dead code carried over unchanged when the method moved into `ParameterGroup`; not introduced by this commit and not a plan violation. +- Suggested fix: Drop the `full_name` bookkeeping when next touching the method. Orchestrator need not forward. + +## Notes + +Plan-task compliance, line by line: +- `ParameterGroup(InstrumentBase)` created at params.py:59 holding all 12 listed tree helpers: `_to_tree` (:72), `to_tree` (:82), `_get_param` (:85), `_get_parent` (:93), `has_param` (:115), `add_parameter` (:122), `remove_parameter` (:149), `get` (:156), `set` (:160), `remove_empty_submodules` (:164), `parameter` (:184), `list` (:192). ✓ +- `ParameterManager(ParameterGroup)` (:208) keeps profiles, files and `workingDirectory` (`__init__` :227, property :239, `refresh_profiles` :291, `fromFile`/`fromParamDict`/`toFile`/`toParamDict` :311-442). No Types/Locks yet — correct for Phase 0.1. ✓ +- `_get_parent(..., create_parent=True)` creates `ParameterGroup(n)` (:109). ✓ +- `_to_tree` assertion changed to `isinstance(sm, ParameterGroup)` (:76); recursion via `cls._to_tree(sm)` still walks nested groups. ✓ +- Acceptance: a `ParameterGroup` has no `refresh_profiles()` and no `fromFile()`, so creating `q01.IF` triggers no `os.listdir(workingDirectory)` and no "parameter file not found" warning, and `isinstance(pm.q01, ParameterGroup)` is True while `isinstance(pm.q01, ParameterManager)` is False — covered by `test_submodules_are_groups` (isinstance asserts + `"parameter file not found" not in caplog.text`). ✓ +- Tests: `test_param_manager.py` all green unchanged; added `test_submodules_are_groups` (test file :220) and `test_submodule_does_not_load_parameter_file` (:239) which plants a `parameter_manager-q01.json` in `tmp_path` and asserts it is NOT loaded into the `q01` submodule (`not params.q01.has_param("file_param")`, `"q01.file_param" not in params.list()`, `params.q01.my_param() == 1`). ✓ + +API-preservation (plan way-of-working rule 7): all of `add_parameter`, `remove_parameter`, `list`, `get`, `set`, `has_param`, `parameter`, `toFile`, `fromFile`, `switch_to_profile`, `refresh_profiles`, `list_profiles`, `to_tree`, `remove_all_parameters`, `remove_empty_submodules` retain their signatures and behaviour on `ParameterManager`. ✓ + +Vocabulary (plan rule 2): new docstrings/comments and test names use glossary terms — "Parameter Group", "Parameter Manager", "Type", "Lock" — consistently. ✓ + +No other call sites in `src/` or `test/` depended on submodules being `ParameterManager` (grep for `_to_tree`, `isinstance(...ParameterManager)`, `ParameterManager(` across the tree confirms only param_manager tests construct submodules via `add_parameter`, which now yield `ParameterGroup`). ✓ + +Scope (plan rule 6 / session step 6): single atomic commit, single task, no out-of-scope fixes. ✓ + +## Tests run + +- `uv run pytest test/pytest/test_param_manager.py` → `12 passed in 5.15s` (all prior tests unchanged-green plus the two new ones). +- `uv run pytest` → `161 passed, 4 warnings in 59.92s`. (A first invocation aborted with a hard PyQt interpreter crash mid-suite in a server-basing test; an immediate re-run completed all 161 green, so the crash is a transient macOS/PyQt flake, not this commit. The affected files were not touched by `params.py`.) \ No newline at end of file diff --git a/orchestration/0.1/round-0/plan-checker-qwen.md b/orchestration/0.1/round-0/plan-checker-qwen.md new file mode 100644 index 0000000..d487b59 --- /dev/null +++ b/orchestration/0.1/round-0/plan-checker-qwen.md @@ -0,0 +1,22 @@ +# 0.1 — plan-checker-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: N/A +- What: No issues found. +- Why: The commit correctly implements all requirements from task 0.1. +- Suggested fix: N/A + +## Notes +- Tests run: `uv run pytest test/pytest/test_param_manager.py` — 12 passed in 5.20s. +- All acceptance criteria met: + - `ParameterGroup(InstrumentBase)` created with tree helpers moved from `ParameterManager`. + - `ParameterManager(ParameterGroup)` extends `ParameterGroup` with file, profile, workingDirectory logic. + - `_get_parent(..., create_parent=True)` creates `ParameterGroup` instances. + - `_to_tree` assertion changed from `ParameterManager` to `ParameterGroup`. + - Creating `q01.IF` no longer lists working directory or logs "parameter file not found". + - `isinstance(pm.q01, ParameterGroup)` is `True`, `isinstance(pm.q01, ParameterManager)` is `False`. + - `test_submodules_are_groups` and `test_submodule_does_not_load_parameter_file` tests added and pass. diff --git a/orchestration/0.1/round-0/reviewer-deepseek.md b/orchestration/0.1/round-0/reviewer-deepseek.md new file mode 100644 index 0000000..43681cb --- /dev/null +++ b/orchestration/0.1/round-0/reviewer-deepseek.md @@ -0,0 +1,20 @@ +# 0.1 — reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/test_param_manager.py:220 +- What: `test_submodules_are_groups` asserts only the "logs 'parameter file not found'" half of the acceptance clause; it never observes "no longer lists the working directory". +- Why: The acceptance reads "creating `q01.IF` no longer lists the working directory **or** logs 'parameter file not found'". Listing the working directory (calling `refresh_profiles()`) is not directly observable from outside a `ParameterGroup` (it has no `.profiles`), and the second added test (`test_submodule_does_not_load_parameter_file`) already proves no file logic runs on a submodule, so the clause is covered by construction. Not a real gap, hence nit. +- Suggested fix: none strictly needed; if desired, the docstring/comment could note the directory-listing clause is covered inductively via `test_submodule_does_not_load_parameter_file`. + +## Notes + +- The split matches the plan (task 0.1 / D15 / D26): all listed tree helpers (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`/`_to_tree`, `list`, `remove_empty_submodules`, dotted `add_parameter`/`remove_parameter`/`get`/`set`) live in `ParameterGroup`; `ParameterManager(ParameterGroup)` keeps `workingDirectory`, profiles, files, `createFromParamDict`, `remove_all_parameters`. `_get_parent(..., create_parent=True)` builds `ParameterGroup(n)` (params.py:109); `_to_tree` asserts `isinstance(sm, ParameterGroup)` (params.py:76). Acceptance `isinstance(pm.q01, ParameterGroup)` and not `ParameterManager` is satisfied and asserted. +- Both required tests were added and are meaningful regression tests: `test_submodule_does_not_load_parameter_file` would have failed before the change (a full `ParameterManager('q01')` would have loaded `parameter_manager-q01.json` and populated `file_param`). +- External callers of `ParameterManager` are unaffected: the public API surface (add_parameter/remove_parameter/list/get/set/has_param/parameter/to_file/from_file/switch_to_profile/refresh_profiles/list_profiles/to_tree/remove_all_parameters/remove_empty_submodules) is inherited unchanged; src/qodex callers (gui/instruments.py, apps.py, client/proxy.py) only construct/reference the root `ParameterManager`. +- Tests run: + - `uv run pytest test/pytest/test_param_manager.py -q` → `12 passed`. + - `uv run pytest -q` → `160 passed, 4 warnings, 1 error`. The single error is a **setup error** for `test/pytest/test_param_manager.py::test_param` ("Exceptions caught in Qt event loop", with a `serialize.py` TypeError `'1.0 is not an int; Parameter: cs_dummy.int_param1'`). It originates during dummy-instrument serialization in `serialize.py` inside the `param_manager` fixture's server startup and is unrelated to this commit's files; I did not re-run the base to confirm pre-existence since running the base requires a write-outside-repo git worktree (disallowed). \ No newline at end of file diff --git a/orchestration/0.1/round-0/reviewer-qwen.md b/orchestration/0.1/round-0/reviewer-qwen.md new file mode 100644 index 0000000..f4398d0 --- /dev/null +++ b/orchestration/0.1/round-0/reviewer-qwen.md @@ -0,0 +1,26 @@ +# 0.1 — reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +No findings. The implementation correctly splits `ParameterGroup` out of `ParameterManager`. + +## Notes + +- Tests run: `uv run pytest test/pytest/test_param_manager.py` — 12 passed. +- The new `ParameterGroup` class holds: + - Tree helpers (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`, `list`, `remove_empty_submodules`) + - Dotted-path methods (`add_parameter`, `remove_parameter`, `get`, `set`) + - No file, profile, Type or Lock logic +- `ParameterManager` now extends `ParameterGroup`, keeping: + - Working directory, profiles, file persistence (`fromFile`, `toFile`, `fromParamDict`, `toParamDict`) + - Profile management (`refresh_profiles`, `switch_to_profile`, `remove_all_parameters`) +- `_to_tree` assertion changed from `isinstance(sm, ParameterManager)` to `isinstance(sm, ParameterGroup)` at line 76. +- `_get_parent(..., create_parent=True)` creates `ParameterGroup(n)` at line 109. +- Two new tests added: + - `test_submodules_are_groups`: verifies submodules are `ParameterGroup` not `ParameterManager`. + - `test_submodule_does_not_load_parameter_file`: verifies parameter files are not loaded into submodules. +- Acceptance criteria met: + - Creating `q01.IF` no longer lists the working directory or logs "parameter file not found". + - `isinstance(pm.q01, ParameterGroup)` is `True` and `isinstance(pm.q01, ParameterManager)` is `False`. diff --git a/orchestration/0.1/round-0/test-reviewer-deepseek.md b/orchestration/0.1/round-0/test-reviewer-deepseek.md new file mode 100644 index 0000000..98de7b6 --- /dev/null +++ b/orchestration/0.1/round-0/test-reviewer-deepseek.md @@ -0,0 +1,25 @@ +# 0.1 — test-reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/test_param_manager.py:220 (test_submodules_are_groups) +- What: The "no longer lists the working directory" half of the first acceptance item is only proven transitively through `isinstance(params.q01, ParameterGroup)`, never asserted directly. +- Why: The caplog assertion (`"parameter file not found" not in caplog.text`) covers only the logging clause of the acceptance, not the working-directory listing clause. A hypothetical regression that re-introduced `refresh_profiles()` (listdir) on submodule creation without the `fromFile()` log would slip past both assertions. The plan acceptance reads "creating `q01.IF` no longer lists the working directory or logs 'parameter file not found'". +- Suggested fix: assert directly that creating a submodule does not touch the working directory — e.g. `monkeypatch` `os.listdir` and assert it is not called (or is called only by the root's own `refresh_profiles`) while running `add_parameter("q01.IF", ...)`, or assert `params.profiles` is unchanged by submodule creation. + +### F2 — nit +- Where: test/pytest/test_param_manager.py:220 (test_submodules_are_groups) +- What: The test runs in the repo's real cwd rather than an isolated `tmp_path`. +- Why: `caplog.clear()` then asserting no "parameter file not found" is emitted depends on there being no `parameter_manager-q01.json` in cwd. If such a file is ever present, the log assertion becomes vacuous (no warning is logged because the file exists), leaving only the isinstance assertion to catch a regression. The sibling test (`test_submodule_does_not_load_parameter_file`) already shows the isolated pattern via `monkeypatch.chdir(tmp_path)`. +- Suggested fix: `monkeypatch.chdir(tmp_path)` (and optionally `monkeypatch.chdir` to a fresh dir) at the top of the test so the caplog assertion is environment-independent. + +## Notes +- Both plan-named tests are present and meaningful. `test_submodules_are_groups` would fail on the pre-split code (old `_get_parent` built `ParameterManager(n)` submodules, so `isinstance(q01, ParameterGroup)` is False and submodule `fromFile()` logs "parameter file not found"). `test_submodule_does_not_load_parameter_file` would also fail on old code: the old submodule `ParameterManager("q01")` would load `parameter_manager-q01.json` whose key `q01.file_param` matches its name filter, making `has_param("file_param")` True. Both new tests are local unit tests, matching the plan's `test_param_manager.py` layer (unit, no server). +- The plan's method-split list is respected in the covered behaviour: `ParameterGroup` carries the tree helpers, `ParameterManager` keeps profiles/files/workingDirectory, `_get_parent(create_parent=True)` creates `ParameterGroup(n)`, and the `_to_tree` assertion is `ParameterGroup`. (Plan-conformance detail; flagging only to confirm nothing about tests conflicts.) +- No existing test was weakened, deleted or skipped. +- Tests run: + - `uv run pytest test/pytest/test_param_manager.py` → `12 passed in 5.15s`. The two new tests are the only additions; all 10 pre-existing tests pass unchanged. + - `uv run pytest` → `1 failed, 160 passed` — the single failure `test_server_gui.py::test_loading_button` is `ZMQError: Address already in use (addr='tcp://127.0.0.1:5555')`, a port-fixed collision between server-bound test modules in the same session. It passes in isolation (`1 passed in 5.37s`) and is unrelated to this commit (`test_param_manager.py` uses no server and never binds 5555). \ No newline at end of file diff --git a/orchestration/0.1/round-0/test-reviewer-qwen.md b/orchestration/0.1/round-0/test-reviewer-qwen.md new file mode 100644 index 0000000..61d6e81 --- /dev/null +++ b/orchestration/0.1/round-0/test-reviewer-qwen.md @@ -0,0 +1,20 @@ +# 0.1 — test-reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — should-fix +- Where: test/pytest/test_param_manager.py:222 +- What: Test `test_submodule_does_not_load_parameter_file` asserts `not isinstance(params.q01, ParameterManager)` but does not explicitly verify the `q01` submodule is a `ParameterGroup`. +- Why: The acceptance criteria in task 0.1 requires that submodules be `ParameterGroup` instances. The test should assert both types explicitly to match the pattern in `test_submodules_are_groups`. +- Suggested fix: Add `assert isinstance(params.q01, ParameterGroup)` at the start of the test. + +### F2 — nit +- Where: src/instrumentserver/params.py:235 +- What: `ParameterManager.__init__` calls `refresh_profiles()` and `fromFile()` unconditionally, but `ParameterGroup` has no such logic. +- Why: The plan task states submodules should not list the working directory or load files. This behavior is already preserved because only `ParameterManager.__init__` (not `ParameterGroup.__init__`) calls these methods; `ParameterGroup` has no `__init__` and `super().__init__` is never invoked on a `ParameterGroup` instance directly in production code. No change is needed, but adding a clarifying comment in `ParameterGroup` would improve maintainability. +- Suggested fix: Add a comment above `class ParameterGroup` noting that it intentionally has no `__init__` to avoid file/profile logic, and that submodules are created via `_get_parent(..., create_parent=True)` using only `ParameterGroup(n)`. + +## Notes +All tests in `test/pytest/test_param_manager.py` pass (12 tests, including the two new ones). The full suite passes (161 tests). The implementation correctly splits `ParameterGroup` out of `ParameterManager`, making submodules plain containers without file/profile logic, and `isinstance(pm.q01, ParameterGroup)` holds while `isinstance(pm.q01, ParameterManager)` does not. diff --git a/orchestration/RUNS.md b/orchestration/RUNS.md new file mode 100644 index 0000000..3d4289e --- /dev/null +++ b/orchestration/RUNS.md @@ -0,0 +1,20 @@ +# Orchestration runs + +## Run 2026-09-23 — run_caa796369a9e + +- Plan: PLAN_parameter_manager_redesign.md +- Tasks: 0.1 (--only 0.1, pilot) +- Branch: marcosfrenkel/new-param-manager +- Starting commit: 447c7f71542e443410684849084ae230cbc8ecfc + +### Report + +| Task | Outcome | Commits | Fix rounds | Final tests | +|---|---|---|---|---| +| 0.1 | done | `46e34cd 0.1: split ParameterGroup out of ParameterManager` | 0 | named file 12 passed; full suite 161 passed, 4 warnings | + +- Six reviewers, all `approve`. One should-fix (test-reviewer-qwen) dropped as factually wrong; five nits not sent. Details: `orchestration/0.1/decisions.md`. +- Open questions for the user: none. +- Workers still alive: none. Stopped because `--only 0.1` was given. +- Permission prompts handled: 5 allowed (all read-only), 1 rejected (reviewer-deepseek tried `git worktree add /tmp/...`). +- Process notes for the next run: (1) reviewer-deepseek's turn ended once without sending worker_done and needed a terminal nudge; (2) three reviewers running `uv run pytest` at the same time collided on fixed ports 5555/5599 and each saw one spurious failure. Consider telling reviewers to run only the task's named test file, or stagger full-suite runs. (3) opencode's bash allowlist misses read-only commands chained with `&&` or prefixed with `cd ... &&`, which caused most prompts. From dcac611241cfbf698885d126a67e8fe11332ffc0 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 16:48:44 -0500 Subject: [PATCH 004/107] Update orca orchestration tooling: switch qwen roles to qwen3.8-27b, add - Swap `lumen/qwen3-coder-next` for `lumen/qwen3.8-27b` across reviewer/test-reviewer/plan-checker roles - Add `wait-event.sh` so orchestrator polls for messages/permission prompts instead of sitting in a long blocking `check --wait`, since permission prompts don't arrive as messages - Allow `cd`, `pwd`, `git branch --show-current`, `lsof`, and `sed -n` for opencode agents - Add plan task 0.0 for per-run test ports (fixes parallel test runs colliding on fixed ports 5555/5556/5599) --- .agents/roles/ROSTER.md | 8 ++-- .agents/skills/orchestrate-plan/SKILL.md | 39 ++++++++++-------- .../orchestrate-plan/scripts/wait-event.sh | 29 +++++++++++++ PLAN_parameter_manager_redesign.md | 17 ++++++++ opencode.json | 41 +++++++++++++++++-- 5 files changed, 109 insertions(+), 25 deletions(-) create mode 100755 .agents/skills/orchestrate-plan/scripts/wait-event.sh diff --git a/.agents/roles/ROSTER.md b/.agents/roles/ROSTER.md index cba648e..da2c414 100644 --- a/.agents/roles/ROSTER.md +++ b/.agents/roles/ROSTER.md @@ -11,11 +11,11 @@ instructions and work with any coding agent. |---|---|---|---|---|---| | `coder` | `coder.md` | opencode | lumen/glm-5.3-flash | `opencode --agent coder` | yes | | `reviewer-deepseek` | `reviewer.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent reviewer-deepseek` | yes | -| `reviewer-qwen` | `reviewer.md` | opencode | lumen/qwen3-coder-next | `opencode --agent reviewer-qwen` | yes | +| `reviewer-qwen` | `reviewer.md` | opencode | lumen/qwen3.8-27b | `opencode --agent reviewer-qwen` | yes | | `test-reviewer-deepseek` | `test-reviewer.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent test-reviewer-deepseek` | yes | -| `test-reviewer-qwen` | `test-reviewer.md` | opencode | lumen/qwen3-coder-next | `opencode --agent test-reviewer-qwen` | yes | +| `test-reviewer-qwen` | `test-reviewer.md` | opencode | lumen/qwen3.8-27b | `opencode --agent test-reviewer-qwen` | yes | | `plan-checker-deepseek` | `plan-checker.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent plan-checker-deepseek` | yes | -| `plan-checker-qwen` | `plan-checker.md` | opencode | lumen/qwen3-coder-next | `opencode --agent plan-checker-qwen` | yes | +| `plan-checker-qwen` | `plan-checker.md` | opencode | lumen/qwen3.8-27b | `opencode --agent plan-checker-qwen` | yes | **Last column.** "yes" means the runner loads the role file itself as standing instructions. "no" means the orchestrator must paste the role file's full text at the top of @@ -27,7 +27,7 @@ Whatever runner fills a role, set up its permission system to match these three opencode they live in `opencode.json`. **Always allowed (all roles):** reading and searching files; `git status`, `diff`, `log`, -`show`, `blame`, `rev-parse`; `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`; +`show`, `blame`, `rev-parse`, `branch --show-current`; `cd`, `pwd`, `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`, `sed -n`, `lsof -nP -i...`; `uv run pytest ...`; the `orca orchestration` worker commands (`check`, `send`, `ask`) that Orca's preamble tells workers to run. diff --git a/.agents/skills/orchestrate-plan/SKILL.md b/.agents/skills/orchestrate-plan/SKILL.md index 3d82dea..17d9e1c 100644 --- a/.agents/skills/orchestrate-plan/SKILL.md +++ b/.agents/skills/orchestrate-plan/SKILL.md @@ -205,27 +205,30 @@ Dispatch id, how to `ask`, how to send `worker_done`) plus your spec into that s ## Waiting +Never sit in a long `check --wait`: permission prompts do not arrive as messages, so +workers would stay stuck until the wait ends. Use the watcher instead. It returns within +about 3 seconds of anything that needs you: + ``` -orca orchestration check --wait --types "worker_done,escalation,question" --timeout-ms 120000 --json +.agents/skills/orchestrate-plan/scripts/wait-event.sh 600 ... ``` -Use a **2-minute** timeout, not the guide's 15 minutes, because permission prompts -(below) do not arrive as messages. On every return: - -1. Process every message in the delivery: - - `question` → answer from the plan if it clearly answers it (`orchestration reply --id - --body ...`) and log it. Otherwise **stop and ask the user**, then reply with - their answer. - - `escalation` → see "Stopping". - - `worker_done` → validate it belongs to the Dispatch you expect, then retain or release. -2. Ack the delivery: `check --ack ...`. -3. Scan every active worker for a permission prompt. Orca does **not** flag these as - needing attention. Read each worker's screen with `orca terminal read --terminal - --json` and look for `Permission required` (details in - `references/permission-prompts.md`). Handle any you find (below). - -A timeout with nothing new is normal. Keep waiting. Follow the guide's rules on empty waits: -never stop, abandon or relaunch a worker without proof its process exited. +It prints one line: + +- `message ` → run `orca orchestration check --json` and process the delivery: + - `question` → answer from the plan if it clearly answers it (`orchestration reply --id + --body ...`) and log it. Otherwise **stop and ask the user**, then reply with + their answer. + - `escalation` → see "Stopping". + - `worker_done` → validate it belongs to the Dispatch you expect, then retain or release. + - Then ack: `orca orchestration check --ack --json`. +- `permission ` → handle the prompt on that worker (below), then run the watcher again. +- `timeout` → 10 minutes with nothing. Normal for long tasks; run it again. After three + timeouts in a row, follow the guide's rule: enumerate with `worker-list` and inspect. + Never stop, abandon or relaunch a worker without proof its process exited. + +Pass only the handles of workers currently working (not retained idle ones), so the +watcher stays fast. ## Permission prompts diff --git a/.agents/skills/orchestrate-plan/scripts/wait-event.sh b/.agents/skills/orchestrate-plan/scripts/wait-event.sh new file mode 100755 index 0000000..3387b38 --- /dev/null +++ b/.agents/skills/orchestrate-plan/scripts/wait-event.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Block until something needs the orchestrator, then print ONE line and exit: +# message an Orca message is waiting (run `orca orchestration check`) +# permission that worker's screen shows an opencode permission prompt +# timeout nothing happened within +# Usage: wait-event.sh ... +# Polls every 3 seconds. Read-only: it never answers prompts or acks messages. +set -u +ORCA="${ORCA_CLI_COMMAND:-orca}" +timeout="${1:?timeout seconds}"; shift +end=$(( $(date +%s) + timeout )) +while :; do + count=$($ORCA orchestration check --peek --json 2>/dev/null | + python3 -c 'import sys,json +try: print(json.load(sys.stdin)["result"]["count"]) +except Exception: print(0)') + if [ "${count:-0}" != "0" ]; then echo "message $count"; exit 0; fi + for h in "$@"; do + if $ORCA terminal read --terminal "$h" --json 2>/dev/null | + python3 -c 'import sys,json +try: tail=json.load(sys.stdin)["result"]["terminal"]["tail"] +except Exception: sys.exit(1) +sys.exit(0 if any("Permission required" in l for l in tail) else 1)'; then + echo "permission $h"; exit 0 + fi + done + [ "$(date +%s)" -ge "$end" ] && { echo timeout; exit 0; } + sleep 3 +done diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 4c83526..5812f64 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -320,6 +320,12 @@ Technical Guide Broadcasts page (with the Broadcaster contract) as Phase 6, then `PMTypeBluePrint`; parameter class `ManagedParameter`; container class `ParameterGroup`; mixin `Broadcaster`. +**D27 — Per-run test ports (added 2026-09-23).** Several agents run the suite at the same +time, and the tests' fixed ports (5555/5556, 5599) made those runs collide. Task 0.0 gives +every pytest session its own free port pair through a `server_port` fixture, and +`AGENTS.md` tells every agent to use it. This widens scope beyond D24 on purpose; it +touches only `test/` and `AGENTS.md`. + --- ## Design reference — translating the mock to Qt @@ -383,6 +389,17 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 0 — Foundations +- [ ] **0.0 Per-run test ports.** Added 2026-09-23 (see Decision record note of that date). + In `test/pytest/conftest.py` add a session-scoped fixture `server_port` that picks two + free consecutive ports once per pytest session (the server binds `port` and uses + `port + 1` for broadcasts). `start_server`, `cli`, the shutdown client in `start_server` + and every test use it instead of a fixed port: `test_client_station.py` (six + `ClientStation(port=5555)` and the `"5555"` assert), `test_server_gui.py` (five + `startServerGuiApplication()` calls), `test_gui_navigation.py` (`TEST_PORT = 5599`). + Add to `AGENTS.md` under "Testing": "Tests never use a fixed port. Use the `server_port` + fixture; agents run the suite in parallel." No change to `src/`. + Acceptance: `grep -rn "5555\|5599" test/pytest` finds nothing; two `uv run pytest` runs + started at the same time both pass. Tests: whole suite green. - [x] **0.1 `ParameterGroup` split.** In `params.py` create `ParameterGroup(InstrumentBase)` holding parameters and nested groups with the tree helpers moved from `ParameterManager` (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`/`_to_tree`, `list`, diff --git a/opencode.json b/opencode.json index bd92fb1..850b7d7 100644 --- a/opencode.json +++ b/opencode.json @@ -34,6 +34,11 @@ "*/orca orchestration send*": "allow", "orca orchestration ask*": "allow", "*/orca orchestration ask*": "allow", + "cd *": "allow", + "pwd": "allow", + "git branch --show-current": "allow", + "lsof -nP -i*": "allow", + "sed -n *": "allow", "git add *": "allow", "git commit -m*": "allow", "git push*": "deny", @@ -92,6 +97,11 @@ "*/orca orchestration send*": "allow", "orca orchestration ask*": "allow", "*/orca orchestration ask*": "allow", + "cd *": "allow", + "pwd": "allow", + "git branch --show-current": "allow", + "lsof -nP -i*": "allow", + "sed -n *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -119,7 +129,7 @@ "reviewer-qwen": { "description": "General code review of one plan task's commits. Read-only. Role: .agents/roles/reviewer.md", "mode": "primary", - "model": "lumen/qwen3-coder-next", + "model": "lumen/qwen3.8-27b", "prompt": "{file:./.agents/roles/reviewer.md}", "permission": { "read": "allow", @@ -152,6 +162,11 @@ "*/orca orchestration send*": "allow", "orca orchestration ask*": "allow", "*/orca orchestration ask*": "allow", + "cd *": "allow", + "pwd": "allow", + "git branch --show-current": "allow", + "lsof -nP -i*": "allow", + "sed -n *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -212,6 +227,11 @@ "*/orca orchestration send*": "allow", "orca orchestration ask*": "allow", "*/orca orchestration ask*": "allow", + "cd *": "allow", + "pwd": "allow", + "git branch --show-current": "allow", + "lsof -nP -i*": "allow", + "sed -n *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -239,7 +259,7 @@ "test-reviewer-qwen": { "description": "Reviews whether the tests prove the task and what is untested. Read-only. Role: .agents/roles/test-reviewer.md", "mode": "primary", - "model": "lumen/qwen3-coder-next", + "model": "lumen/qwen3.8-27b", "prompt": "{file:./.agents/roles/test-reviewer.md}", "permission": { "read": "allow", @@ -272,6 +292,11 @@ "*/orca orchestration send*": "allow", "orca orchestration ask*": "allow", "*/orca orchestration ask*": "allow", + "cd *": "allow", + "pwd": "allow", + "git branch --show-current": "allow", + "lsof -nP -i*": "allow", + "sed -n *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -332,6 +357,11 @@ "*/orca orchestration send*": "allow", "orca orchestration ask*": "allow", "*/orca orchestration ask*": "allow", + "cd *": "allow", + "pwd": "allow", + "git branch --show-current": "allow", + "lsof -nP -i*": "allow", + "sed -n *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -359,7 +389,7 @@ "plan-checker-qwen": { "description": "Checks commits against the plan, glossary, decisions and ADRs. Read-only. Role: .agents/roles/plan-checker.md", "mode": "primary", - "model": "lumen/qwen3-coder-next", + "model": "lumen/qwen3.8-27b", "prompt": "{file:./.agents/roles/plan-checker.md}", "permission": { "read": "allow", @@ -392,6 +422,11 @@ "*/orca orchestration send*": "allow", "orca orchestration ask*": "allow", "*/orca orchestration ask*": "allow", + "cd *": "allow", + "pwd": "allow", + "git branch --show-current": "allow", + "lsof -nP -i*": "allow", + "sed -n *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", From 71aa9af15f258699725128b2f231b7bfa9a85773 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 17:11:40 -0500 Subject: [PATCH 005/107] 0.0: per-run test ports via session-scoped server_port fixture --- AGENTS.md | 3 +++ test/pytest/conftest.py | 42 ++++++++++++++++++++++++++---- test/pytest/test_apps.py | 17 +++++++----- test/pytest/test_client_station.py | 26 +++++++++--------- test/pytest/test_gui_navigation.py | 32 +++++++++++------------ test/pytest/test_server_gui.py | 32 ++++++++++++++++------- 6 files changed, 101 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 54a0869..fa27e27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,10 @@ BaseClient → ZMQ Network → ThreadPool + Broadcast ## Testing +Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel. + ### Test Setup (test/pytest/conftest.py) +- `server_port` fixture - Session-scoped pair of free consecutive ports (server port and broadcast port) - `start_server` fixture - Module-scoped server for all tests - `cli` fixture - New Client per test - `dummy_instrument` fixture - Test dummy instrument with submodules diff --git a/test/pytest/conftest.py b/test/pytest/conftest.py index 8abfed9..c81175e 100644 --- a/test/pytest/conftest.py +++ b/test/pytest/conftest.py @@ -1,3 +1,6 @@ +import random +import socket + import pytest # type: ignore[import-not-found] import qcodes as qc @@ -6,6 +9,35 @@ from instrumentserver.server.core import startServer +@pytest.fixture(scope="session") +def server_port(): + """Pick a free pair of consecutive ports, once per pytest session. + + The Server binds ``port`` for requests and uses ``port + 1`` for + Broadcasts, so both must be free. Several agents run the suite in + parallel; fixed ports made those runs collide. The first port is + drawn randomly from a wide range — deliberately outside the OS + ephemeral port range, whose sequential allocation hands concurrently + starting sessions adjacent, overlapping pairs — and both ports are + then verified to be free. + """ + + def _pair_is_free(port): + try: + with socket.socket() as first, socket.socket() as second: + first.bind(("", port)) + second.bind(("", port + 1)) + return True + except OSError: + return False + + for _ in range(100): + port = random.randrange(20_000, 40_000) + if _pair_is_free(port): + return port + raise RuntimeError("Could not find two free consecutive ports.") + + @pytest.fixture(autouse=True, scope="module") def _close_instruments_between_modules(): """Ensure every test module starts with a clean qcodes instrument registry. @@ -37,14 +69,14 @@ def qapp_session(): @pytest.fixture(scope="module") -def start_server(qapp_session): - server, thread = startServer() +def start_server(qapp_session, server_port): + server, thread = startServer(port=server_port) yield server # The zmq loop in StationServer blocks on poll(); thread.quit() on its own # won't interrupt it. Send the SAFEWORD so the server shuts itself down, # then wait for the thread's event loop to exit. try: - with BaseClient() as shutdown_cli: + with BaseClient(port=server_port) as shutdown_cli: shutdown_cli.ask(server.SAFEWORD) except Exception: pass @@ -53,8 +85,8 @@ def start_server(qapp_session): @pytest.fixture() -def cli(start_server): - cli = Client() +def cli(start_server, server_port): + cli = Client(port=server_port) yield cli cli.disconnect() diff --git a/test/pytest/test_apps.py b/test/pytest/test_apps.py index 4469cf9..80e2aaf 100644 --- a/test/pytest/test_apps.py +++ b/test/pytest/test_apps.py @@ -19,6 +19,8 @@ import pytest +from instrumentserver import DEFAULT_PORT + # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- @@ -113,7 +115,7 @@ def test_server_script_gui_default_no_config(): mock_lc.assert_not_called() kwargs = mock_gui.call_args.kwargs - assert kwargs["port"] == 5555 + assert kwargs["port"] == DEFAULT_PORT assert kwargs["addresses"] is None assert kwargs["serverConfig"] is None assert kwargs["stationConfig"] is None @@ -290,7 +292,7 @@ def test_client_station_script_no_config(): clientStationScript() - mock_cs.assert_called_once_with(host="localhost", port=5555, config_path=None) + mock_cs.assert_called_once_with(host="localhost", port=DEFAULT_PORT, config_path=None) def test_client_station_script_with_config(tmp_path): @@ -307,11 +309,12 @@ def test_client_station_script_with_config(tmp_path): clientStationScript() - mock_cs.assert_called_once_with(host="localhost", port=5555, config_path=cfg) + mock_cs.assert_called_once_with(host="localhost", port=DEFAULT_PORT, config_path=cfg) def test_detached_server_script_defaults(): - """detachedServerScript: defaults → DetachedServerGui called with host=localhost, port=5555.""" + """detachedServerScript: defaults → DetachedServerGui called with the + default host and the package's default port.""" sys.argv = ["instrumentserver-detached"] with ( patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, @@ -323,7 +326,7 @@ def test_detached_server_script_defaults(): detachedServerScript() - mock_dsg.assert_called_once_with(host="localhost", port=5555) + mock_dsg.assert_called_once_with(host="localhost", port=DEFAULT_PORT) def test_detached_server_script_custom(): @@ -351,7 +354,7 @@ def test_detached_server_script_custom(): def test_param_manager_script_instrument_exists(): """parameterManagerScript: instrument exists → get_instrument path taken.""" - sys.argv = ["instrumentserver-param-manager", "--port", "5555"] + sys.argv = ["instrumentserver-param-manager", "--port", "4567"] mock_pm = MagicMock() mock_cli = MagicMock() mock_cli.list_instruments.return_value = ["parameter_manager"] @@ -376,7 +379,7 @@ def test_param_manager_script_instrument_exists(): def test_param_manager_script_instrument_missing(): """parameterManagerScript: instrument not found → find_or_create path taken.""" - sys.argv = ["instrumentserver-param-manager", "--port", "5555"] + sys.argv = ["instrumentserver-param-manager", "--port", "4567"] mock_pm = MagicMock() mock_cli = MagicMock() mock_cli.list_instruments.return_value = [] diff --git a/test/pytest/test_client_station.py b/test/pytest/test_client_station.py index 495ceee..e110e7d 100644 --- a/test/pytest/test_client_station.py +++ b/test/pytest/test_client_station.py @@ -15,8 +15,8 @@ @pytest.fixture(scope="module") -def client_station(start_server): - station = ClientStation(host="localhost", port=5555) +def client_station(start_server, server_port): + station = ClientStation(host="localhost", port=server_port) yield station station.disconnect() @@ -73,10 +73,10 @@ def test_client_station_subscript_access(client_station): # --------------------------------------------------------------------------- -def test_client_station_gui_opens(qtbot, start_server): +def test_client_station_gui_opens(qtbot, start_server, server_port): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) window = ClientStationGui(station) qtbot.addWidget(window) try: @@ -86,10 +86,10 @@ def test_client_station_gui_opens(qtbot, start_server): station.disconnect() -def test_client_station_gui_has_three_tabs(qtbot, start_server): +def test_client_station_gui_has_three_tabs(qtbot, start_server, server_port): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) window = ClientStationGui(station) qtbot.addWidget(window) try: @@ -102,24 +102,24 @@ def test_client_station_gui_has_three_tabs(qtbot, start_server): station.disconnect() -def test_client_station_gui_server_widget_shows_host_port(qtbot, start_server): +def test_client_station_gui_server_widget_shows_host_port(qtbot, start_server, server_port): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) window = ClientStationGui(station) qtbot.addWidget(window) try: assert window.server_widget.host.text() == "localhost" - assert window.server_widget.port.text() == "5555" + assert window.server_widget.port.text() == str(server_port) finally: window.close() station.disconnect() -def test_client_station_gui_station_list_populated(qtbot, start_server): +def test_client_station_gui_station_list_populated(qtbot, start_server, server_port): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) station.find_or_create_instrument("gui_cs_dummy", DUMMY_CLASS) window = ClientStationGui(station) qtbot.addWidget(window) @@ -130,12 +130,12 @@ def test_client_station_gui_station_list_populated(qtbot, start_server): station.disconnect() -def test_client_station_gui_open_instrument_tab(qtbot, start_server): +def test_client_station_gui_open_instrument_tab(qtbot, start_server, server_port): from instrumentserver import QtCore from instrumentserver.client.application import ClientStationGui from instrumentserver.gui.instruments import GenericInstrument - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) station.find_or_create_instrument("gui_cs_dummy2", DUMMY_CLASS) window = ClientStationGui(station) qtbot.addWidget(window) diff --git a/test/pytest/test_gui_navigation.py b/test/pytest/test_gui_navigation.py index e620882..de36867 100644 --- a/test/pytest/test_gui_navigation.py +++ b/test/pytest/test_gui_navigation.py @@ -21,15 +21,15 @@ def _shutdown_server_window(qtbot, window): qtbot.waitUntil(lambda: not thread.isRunning(), timeout=10000) -# Use a spare port so these tests never talk to a developer's live server on 5555. -TEST_PORT = 5599 - +# Each pytest session gets its own free port pair from the server_port +# fixture, so these tests never collide with a developer's live server or +# another concurrently running test suite. TIMEOUT_INS = ( "instrumentserver.testing.dummy_instruments.generic.DummyInstrumentTimeout" ) -def _start_window(qtbot): +def _start_window(qtbot, port): """Create the server window and wait until its embedded client points at the test server. @@ -37,9 +37,9 @@ def _start_window(qtbot): only re-targets the real server port once the event loop delivers the server-started signal, so the first request must not be sent before then. """ - window = startServerGuiApplication(port=TEST_PORT) + window = startServerGuiApplication(port=port) qtbot.addWidget(window) - qtbot.waitUntil(lambda: window.client.addr.endswith(f":{TEST_PORT}"), timeout=10000) + qtbot.waitUntil(lambda: window.client.addr.endswith(f":{port}"), timeout=10000) return window @@ -62,8 +62,8 @@ def _find_row(view, text): return matches[0] -def test_backspace_does_not_blank_read_only_parameter(qtbot): - window = _start_window(qtbot) +def test_backspace_does_not_blank_read_only_parameter(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "timeout", TIMEOUT_INS) params = tab.parametersList @@ -84,8 +84,8 @@ def test_backspace_does_not_blank_read_only_parameter(qtbot): _shutdown_server_window(qtbot, window) -def test_backspace_clears_editable_parameter(qtbot): - window = _start_window(qtbot) +def test_backspace_clears_editable_parameter(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "timeout", TIMEOUT_INS) params = tab.parametersList @@ -109,8 +109,8 @@ def test_backspace_clears_editable_parameter(qtbot): ) -def test_enter_toggles_node_with_children(qtbot): - window = _start_window(qtbot) +def test_enter_toggles_node_with_children(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "dummy", SUBMODULE_INS) view = tab.parametersList.view @@ -132,8 +132,8 @@ def test_enter_toggles_node_with_children(qtbot): _shutdown_server_window(qtbot, window) -def test_right_expands_node_then_moves_to_first_child(qtbot): - window = _start_window(qtbot) +def test_right_expands_node_then_moves_to_first_child(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "dummy", SUBMODULE_INS) view = tab.parametersList.view @@ -152,8 +152,8 @@ def test_right_expands_node_then_moves_to_first_child(qtbot): _shutdown_server_window(qtbot, window) -def test_enter_on_parameter_requests_edit(qtbot): - window = _start_window(qtbot) +def test_enter_on_parameter_requests_edit(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "dummy", SUBMODULE_INS) view = tab.parametersList.view diff --git a/test/pytest/test_server_gui.py b/test/pytest/test_server_gui.py index 435e0cb..a420fa9 100644 --- a/test/pytest/test_server_gui.py +++ b/test/pytest/test_server_gui.py @@ -22,7 +22,14 @@ def _shutdown_server_window(window): pass -def test_saving_button(qtbot): +def _wait_until_client_points_at_server(qtbot, window, port): + """The embedded client connects to the default port when it is constructed + and only re-targets the real server port once the event loop delivers the + server-started signal, so the first request must not be sent before then.""" + qtbot.waitUntil(lambda: window.client.addr.endswith(f":{port}"), timeout=10000) + + +def test_saving_button(qtbot, server_port): correct_file_dict = { "rr.bandwidth": 10000.0, "rr.data": None, @@ -37,7 +44,8 @@ def test_saving_button(qtbot): "rr.stop_frequency": 20000000000.0, } - window = startServerGuiApplication() + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) window.client.find_or_create_instrument( "rr", "instrumentserver.testing.dummy_instruments.rf.ResonatorResponse" ) @@ -58,7 +66,7 @@ def test_saving_button(qtbot): _shutdown_server_window(window) -def test_loading_button(qtbot): +def test_loading_button(qtbot, server_port): correct_file_dict = { "dummy.A.ch0": 0, "dummy.A.ch1": 1, @@ -70,7 +78,8 @@ def test_loading_button(qtbot): "dummy.param1": 1, } - window = startServerGuiApplication() + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) file_path = Path(window._paramValuesFile) @@ -94,8 +103,9 @@ def test_loading_button(qtbot): _shutdown_server_window(window) -def test_refresh_button(qtbot): - window = startServerGuiApplication() +def test_refresh_button(qtbot, server_port): + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) qtbot.addWidget(window) try: assert window.stationList.topLevelItemCount() == 0 @@ -113,10 +123,11 @@ def test_refresh_button(qtbot): _shutdown_server_window(window) -def test_clicking_an_item(qtbot): +def test_clicking_an_item(qtbot, server_port): # If there is an exception raise, it will not reach the assert True statement - window = startServerGuiApplication() + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) qtbot.addWidget(window) try: assert window.stationList.topLevelItemCount() == 0 @@ -138,8 +149,9 @@ def test_clicking_an_item(qtbot): _shutdown_server_window(window) -def test_opening_new_tab_generic_object(qtbot): - window = startServerGuiApplication() +def test_opening_new_tab_generic_object(qtbot, server_port): + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) qtbot.addWidget(window) try: window.client.find_or_create_instrument( From a1bce5e9bf8d2e3be7f6bc9c19f2ec74057e9ed5 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 17:51:44 -0500 Subject: [PATCH 006/107] 0.0: orchestration record --- PLAN_parameter_manager_redesign.md | 2 +- orchestration/0.0/decisions.md | 93 +++++++++++++++++++ orchestration/0.0/round-0/fix-list.md | 3 + .../0.0/round-0/plan-checker-deepseek.md | 23 +++++ .../0.0/round-0/plan-checker-qwen.md | 28 ++++++ .../0.0/round-0/reviewer-deepseek.md | 26 ++++++ orchestration/0.0/round-0/reviewer-qwen.md | 23 +++++ .../0.0/round-0/test-reviewer-deepseek.md | 60 ++++++++++++ .../0.0/round-0/test-reviewer-qwen.md | 26 ++++++ orchestration/RUNS.md | 7 ++ 10 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 orchestration/0.0/decisions.md create mode 100644 orchestration/0.0/round-0/fix-list.md create mode 100644 orchestration/0.0/round-0/plan-checker-deepseek.md create mode 100644 orchestration/0.0/round-0/plan-checker-qwen.md create mode 100644 orchestration/0.0/round-0/reviewer-deepseek.md create mode 100644 orchestration/0.0/round-0/reviewer-qwen.md create mode 100644 orchestration/0.0/round-0/test-reviewer-deepseek.md create mode 100644 orchestration/0.0/round-0/test-reviewer-qwen.md diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 5812f64..0b76a90 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -389,7 +389,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 0 — Foundations -- [ ] **0.0 Per-run test ports.** Added 2026-09-23 (see Decision record note of that date). +- [x] **0.0 Per-run test ports.** Added 2026-09-23 (see Decision record note of that date). In `test/pytest/conftest.py` add a session-scoped fixture `server_port` that picks two free consecutive ports once per pytest session (the server binds `port` and uses `port + 1` for broadcasts). `start_server`, `cli`, the shutdown client in `start_server` diff --git a/orchestration/0.0/decisions.md b/orchestration/0.0/decisions.md new file mode 100644 index 0000000..c703a18 --- /dev/null +++ b/orchestration/0.0/decisions.md @@ -0,0 +1,93 @@ +# 0.0 Per-run test ports — decisions log + +Run: run_da269441b6ac. Branch: marcosfrenkel/new-param-manager. Base commit: dcac611241cfbf698885d126a67e8fe11332ffc0. + +## Workers + +| agent id | terminal handle | current dispatch id | +|---|---|---| +| coder | term_265a1d63-a53f-4c89-9103-d08073b3b0b3 | ctx_6c5e0216219c (task_68f47a6717b1, first implementation) | +| reviewer-deepseek | term_81965952-a904-424d-b7a4-bf6a360895ac | ctx_8be3180e5d27 (task_695911280dbd, round 0) | +| reviewer-qwen | term_d1192fcb-3292-40e1-ad8e-6043a40f7464 | ctx_75ea9094771f (task_010246861426, round 0) | +| test-reviewer-deepseek | term_6d9a052f-618c-4324-8cb8-35bdd7ddee25 | ctx_53f1564807de (task_af96c8b4bdc3, round 0) | +| test-reviewer-qwen | term_d71ca1ad-f33f-4554-8a5e-b7f7ea729d0f | ctx_24e43f066a17 (task_581a2dfb9397, round 0) | +| plan-checker-deepseek | term_80d55039-74cd-4161-a984-f9962b009a99 | ctx_a19a7968ea5d (task_ccd049dd0017, round 0) | +| plan-checker-qwen | term_7ed74875-6931-4360-bccc-20e42423ada6 | ctx_9fdc13e828f5 (task_78ca9a1a4ef8, round 0) | + +## Log + +- 16:51 Run created. Checkbox 0.0 set to [~]. Base dcac611. +- Orchestrator note before dispatch: `test/pytest/test_apps.py` also contains the literal 5555 (argparse-default assertions, not a live server port) but the task text does not list it, while the acceptance grep requires the literal to be gone from test/pytest. Decision: the acceptance line is the rule; the spec tells the coder to satisfy it for test_apps.py without changing src/ (e.g. derive the expected default from the parser or a src constant). Logged here so reviewers can check it. +- 16:52 Coder dispatched for first implementation (task_68f47a6717b1 / ctx_6c5e0216219c). +- 16:53 Permission: coder asked to run chained rg/grep caller-check search over src/ and test/. Allowed once: read-only. +- 16:54 Permission: coder asked to run perl -pi replacing port=5555 with port=server_port in test/pytest/test_client_station.py. Allowed once: edit of a test file the task names, equivalent to the edit tool. +- 16:55 Permission: coder asked to run perl -pi adding the server_port fixture to test signatures in test/pytest/test_client_station.py. Allowed once: edit of a named test file. +- 16:55 Permission: coder asked to run perl -pi on test/pytest/test_server_gui.py (server_port fixture + startServerGuiApplication(port=server_port)). Allowed once: edit of a named test file. +- 16:55 Permission: coder asked to run perl -pi on test/pytest/test_gui_navigation.py (server_port fixture threading). Allowed once: edit of a named test file. +- 16:56 Permission: coder asked to run the acceptance grep chained with echo. Allowed once: read-only. +- 16:56 Permission: coder asked to delete test/pytest/__pycache__/*.pyc before the acceptance grep (stale bytecode matched the literal). Allowed once: regenerable files inside the repo, untracked. +- 17:03 Permission: coder asked to run lsof/ps to check for leftover servers on 5555. Allowed once: read-only. +- 17:03 Permission: coder asked to run perl -pi on test/pytest/test_server_gui.py adding a wait helper after startServerGuiApplication. Allowed once: edit of a named test file. +- 17:05 Permission: coder asked to run two concurrent 'uv run pytest -q' with logs in opencode's temp dir (the acceptance check the spec asked for). Allowed once: test command, logs outside repo but in the runner's own temp dir. +- 17:07 Permission: coder asked to rg its two pytest logs for port lines and failures. Allowed once: read-only. +- 17:08 Permission: coder re-ran the two concurrent 'uv run pytest -q' (second attempt after a fix). Allowed once: same as before. +- 17:10 Permission: coder ran the two concurrent pytest runs a third time (run3/run4 logs). Allowed once. +- 17:11 Permission: coder asked pyc cleanup + acceptance grep + git status/diff --stat. Allowed once: read-only apart from regenerable bytecode. +- Coder worker_done (succeeded). Commit 71aa9af "0.0: per-run test ports via session-scoped server_port fixture"; files: AGENTS.md, test/pytest/{conftest,test_apps,test_client_station,test_gui_navigation,test_server_gui}.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit, nothing dirty outside orchestration/ and the plan. Coder retained. +- Coder reported: test_apps.py now derives argparse-default asserts from src DEFAULT_PORT (per orchestrator note); test_server_gui.py needed a wait for the embedded client to re-target (race visible only with dynamic ports); first sequential ephemeral-port probing gave overlapping pairs between two sessions, replaced by random wide-range selection. +- Orchestrator acceptance: grep 5555|5599 over test/pytest (excluding __pycache__) -> nothing (exit 1). Two concurrent `uv run pytest -q`: 161 passed, 4 warnings in 59.06s / 161 passed, 4 warnings in 58.64s. +- 17:14 Six reviewers dispatched for round 0 (target dcac611..71aa9af). +- 17:15 Permission: plan-checker-deepseek asked cat decisions.md + git show --stat (read-only). Allowed once. +- 17:15 Permission: plan-checker-qwen asked git show : | sed -n (read-only). Allowed once. +- 17:15 Permission: test-reviewer-qwen asked the acceptance grep (read-only). Allowed once. +- 17:16 Permission: plan-checker-deepseek asked the acceptance grep (read-only). Allowed once. +- 17:16 Permission: reviewer-deepseek asked the acceptance grep (read-only). Allowed once. +- 17:16 Permission: test-reviewer-deepseek asked the acceptance grep via rg (read-only). Allowed once. +- 17:16 Permission: reviewer-qwen asked the acceptance grep (read-only). Allowed once. +- 17:17 Permission: test-reviewer-qwen asked rg for port literals + ls (read-only). Allowed once. +- 17:17 Permission: plan-checker-deepseek asked grep for remaining fixed-port call sites (read-only). Allowed once. +- 17:17 Permission: test-reviewer-qwen asked rg/cat over test config (read-only). Allowed once. +- 17:18 Permission: plan-checker-deepseek asked grep DEFAULT_PORT in src (read-only). Allowed once. +- 17:18 Permission: test-reviewer-qwen asked cat pytest.ini / grep pyproject / ls (read-only). Allowed once. +- 17:18 Permission: plan-checker-qwen asked the acceptance grep via rg (read-only). Allowed once. +- 17:18 Permission: test-reviewer-qwen asked ls round-0 + git diff --stat src/ (read-only). Allowed once. +- 17:19 Permission: plan-checker-deepseek asked wider grep for port literals in test/ (read-only). Allowed once. +- 17:19 Permission: plan-checker-qwen asked ls __pycache__ (read-only). Allowed once. +- 17:19 Permission: reviewer-qwen asked git status + diff --name-only (read-only). Allowed once. +- 17:20 Permission: plan-checker-deepseek asked port-literal grep over test/ (read-only). Allowed once. +- 17:20 reviewer-qwen worker_done (succeeded, approve, 1 nit). Retained. +- 17:20 Permission: test-reviewer-deepseek asked ls round-0 (read-only). Allowed once. +- 17:21 Permission: test-reviewer-qwen asked access to opencode's temp dir to write two concurrent pytest logs (verifying acceptance); same as allowed for the coder, outside the repo, no repo writes. Allowed once. +- 17:21 Permission: test-reviewer-qwen ran the two concurrent pytest runs with logs in opencode's temp dir (shell-command half of the previous request). Allowed once. +- 17:21 Permission: test-reviewer-deepseek asked its own orca orchestration check (suffix broke the allowlist). Allowed once. +- 17:22 Permission: plan-checker-deepseek asked grep -c + git log/diff on src (read-only). Allowed once. +- 17:22 test-reviewer-deepseek worker_done (succeeded, approve, 2 nits). Retained. +- 17:22 Permission: plan-checker-deepseek asked git show : | grep DEFAULT_PORT (read-only). Allowed once. +- 17:23 Permission: plan-checker-qwen re-ran the acceptance grep (read-only). Allowed once. +- 17:23 Permission: test-reviewer-qwen asked git diff | rg for weakened tests (read-only). Allowed once. +- 17:23 test-reviewer-qwen worker_done (succeeded, approve, 1 nit). Retained. +- 17:24 Permission: plan-checker-qwen asked acceptance grep + git diff --stat src (read-only). Allowed once. +- 17:25 Permission: plan-checker-qwen asked rg over the three touched test files (read-only). Allowed once. +- 17:26 plan-checker-qwen worker_done (succeeded, approve, 2 nits; flags the plan's Testing line 'own server on a fixed port >= 5600' as now stale vs D27). Retained. +- 17:27 plan-checker-deepseek worker_done (succeeded, approve, 2 nits). Retained. +- 17:37 reviewer-deepseek: model output degenerated into garbage, turn ended idle (liveness live) with no report and no worker_done after ~10 min. Nudged in its terminal to write the report and send worker_done. +- 17:38 Permission: reviewer-deepseek asked two concurrent pytest runs on two test files (test command). Allowed once. +- 17:39 Permission: reviewer-deepseek asked ls of orchestration/0.0 (read-only). Allowed once. +- 17:49 reviewer-deepseek: second idle stop after concluding approve without writing the report. Second nudge sent. +- 17:51 reviewer-deepseek worker_done after second nudge (succeeded, approve, 2 nits). Retained. All six round-0 reports present. + +## Round 0 merge (six reports, all `approve`) + +- Docstring of `server_port` says the 20000-40000 range is "outside the OS ephemeral port range", true on macOS only (Linux starts at 32768). Raised as nit by reviewer-deepseek F1, reviewer-qwen F1, test-reviewer-qwen F1, plan-checker-qwen F1. All rated nit; behaviour is protected by the bind check both models confirm. Not sent: nit (wording). Worth fixing when a later task touches conftest.py. +- `_wait_until_client_points_at_server` in test_server_gui.py duplicates the wait in test_gui_navigation._start_window. reviewer-deepseek F2 (nit), noted approvingly by plan-checker-deepseek F2 and both test reviewers as a necessary race fix. Not sent: nit (refactor preference). +- test_apps.py `--port 4567` argv literal in two mocked launcher tests. plan-checker-qwen F2, plan-checker-deepseek F1 (both nit, both say no fix required; clients are MagicMocks, no socket). Not sent: nit. +- Fixture has a check-to-bind window (test-reviewer-deepseek F1, nit) and no dedicated fixture unit test (test-reviewer-deepseek F2, nit; test-reviewer-qwen notes the same and accepts it since the plan names none). Not sent: nit; plan's test criterion is "whole suite green". +- plan-checker-qwen question for the user: the plan's Testing section still says GUI tests use "own server on a fixed port >= 5600", which D27 made stale. Plan text change, so not sent to the coder; raised to the user in the run report. +- Fix list: EMPTY. Task goes to finish. + +## Finish + +- All seven workers released (Orca kept the externally created terminals: state retained, processAction none) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_da269441b6ac: 0 rows. +- Checkbox 0.0 set to [x]. + +**Summary.** Outcome: done. Commits: `71aa9af 0.0: per-run test ports via session-scoped server_port fixture`. Fix rounds used: 0. Tests (orchestrator run, two concurrent): `uv run pytest -q` -> 161 passed, 4 warnings in 59.06s / 161 passed, 4 warnings in 58.64s. Acceptance grep for 5555|5599 in test/pytest: nothing. diff --git a/orchestration/0.0/round-0/fix-list.md b/orchestration/0.0/round-0/fix-list.md new file mode 100644 index 0000000..e3eb523 --- /dev/null +++ b/orchestration/0.0/round-0/fix-list.md @@ -0,0 +1,3 @@ +# 0.0 — fix list after round 0 + +Empty. All six reviewers returned `approve`; every finding was a nit. See decisions.md for the per-finding log. diff --git a/orchestration/0.0/round-0/plan-checker-deepseek.md b/orchestration/0.0/round-0/plan-checker-deepseek.md new file mode 100644 index 0000000..b5a3336 --- /dev/null +++ b/orchestration/0.0/round-0/plan-checker-deepseek.md @@ -0,0 +1,23 @@ +# 0.0 — plan-checker-deepseek — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/test_apps.py:354,379 (the two `parameterManagerScript` tests) +- What: the plan task text lists only `test_client_station.py`, `test_server_gui.py`, `test_gui_navigation.py` for the port change; `test_apps.py` is handled per the orchestrator note, so the coder swapping `--port 5555` → `--port 4567` in these two tests is within the logged decision, not in-scope. +- Why: Orca decisions.md records: "the task text does not list that file but its acceptance grep requires the literal gone from all of test/pytest, so the orchestrator told the coder to ... derive the expected default from src without changing src/." The `4567` makes the grep literal-free while src stays untouched (verified `git diff dcac611..71aa9af -- src/` is empty and `DEFAULT_PORT = 5555` exists unchanged in `src/instrumentserver/__init__.py`). +- Suggested fix: none required. + +### F2 — nit +- Where: test/pytest/test_server_gui.py:25-30 (`_wait_until_client_points_at_server`) +- What: a small wait helper added beyond the literal task text (which only says the five `startServerGuiApplication()` calls use the port) so the embedded client re-targets the dynamic port before the first request. +- Why: the plan's conftest prose states the server binds `port` and uses `port + 1` for broadcasts; with fixed ports the embedded client's initial default-port connection happened to be harmless, but a dynamic port makes the first request race. The helper makes the named tests honest without touching `src/`, consistent with "No change to `src/`" ("No change to `src/`."). +- Suggested fix: none required; reads as necessary accommodation for dynamic ports. + +## Notes +- Tests run: `uv run pytest -q` — `161 passed, 4 warnings in 59.12s` (warnings are pre-existing unknown pytest.mark.integration marks). Matches the orchestrator's two concurrent runs (161 passed each). +- Confirmed acceptance: `grep -rn "5555\|5599" test/pytest` (excluding __pycache__) returns nothing (exit 1). +- Remaining `5555`/`5599` literals elsewhere in `test/` (`test_config.py` `:5556` externalBroadcast yaml-content assertion, `test_async_requests/*`, `test/notebooks`, `docs_verification/helpers.py`) are outside the `test/pytest` acceptance scope and are not live server ports targeted by this task; correctly left alone. +- The `server_port` fixture ranges 20k–40k, out of the OS ephemeral range, and verifies both the request port and `port + 1` are free before returning — satisfies "picks two free consecutive ports once per pytest session". \ No newline at end of file diff --git a/orchestration/0.0/round-0/plan-checker-qwen.md b/orchestration/0.0/round-0/plan-checker-qwen.md new file mode 100644 index 0000000..709d1d6 --- /dev/null +++ b/orchestration/0.0/round-0/plan-checker-qwen.md @@ -0,0 +1,28 @@ +# 0.0 — plan-checker-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/conftest.py:19-20 +- What: the `server_port` docstring says the random range is "deliberately outside the OS ephemeral port range", which is only true on macOS (49152–65535); on Linux the default ephemeral range (32768–60999) overlaps the top of the 20000–40000 range. +- Why: cosmetic inaccuracy only — the bind-check on `port` and `port + 1` is authoritative, satisfying the task's "picks two free consecutive ports once per pytest session", so behaviour is unaffected. +- Suggested fix: soften the docstring to "drawn from a wide range and verified free by binding" without the platform-specific claim. + +### F2 — nit +- Where: test/pytest/test_apps.py:357, test/pytest/test_apps.py:382 +- What: the two param-manager launcher tests use a new arbitrary fixed literal `"4567"` in `sys.argv` instead of a fixture-derived port. +- Why: the AGENTS.md line added by this task says "Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel." — but these tests mock `Client` and bind nothing, so no live port is involved and the acceptance grep (5555/5599) is clean; `server_port` would also be meaningless here. Flagging only for future consistency (the pre-existing `9000` at line 334/346 is the same kind of mocked-argv literal). +- Suggested fix: none required; optionally note in a comment that the value is inert argv for a mocked launcher. + +## Notes + +- Scope check: commit 71aa9af touches exactly `AGENTS.md`, `test/pytest/conftest.py`, `test/pytest/test_apps.py`, `test/pytest/test_client_station.py`, `test/pytest/test_gui_navigation.py`, `test/pytest/test_server_gui.py` — `git diff --stat dcac611..71aa9af -- src/` is empty, matching "No change to `src/`". +- Task items, point by point: session-scoped `server_port` fixture added (random range + both ports bind-verified, matching "the server binds `port` and uses `port + 1` for broadcasts" — confirmed `self.broadcastPort = self.port + 1` in `server/core.py:152`); `start_server`, the shutdown `BaseClient(port=server_port)` and `cli`/`Client(port=server_port)` all use it; all six `ClientStation(port=5555)` and the `"5555"` assert in `test_client_station.py` converted; all five `startServerGuiApplication()` calls in `test_server_gui.py` now pass `port=server_port` (plus a `_wait_until_client_points_at_server` helper — justified: the embedded client re-targets its port asynchronously after the server-started signal, so without it the tests race only under dynamic ports; orchestrator decisions.md logged this); `TEST_PORT = 5599` removed from `test_gui_navigation.py` with the port threaded through `_start_window(qtbot, port)` and all five tests. +- `test_apps.py` was not in the task text; per the orchestrator decision (orchestration/0.0/decisions.md) the acceptance grep is the rule, and the coder satisfied it by importing `DEFAULT_PORT` from `instrumentserver` (`DEFAULT_PORT = 5555` in `src/instrumentserver/__init__.py`) for the four argparse-default assertions — `src/` untouched, as instructed. +- AGENTS.md: the mandated sentence "Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel." is present verbatim under "Testing", plus one extra bullet listing the `server_port` fixture in the "Test Setup" list — in an in-scope file, directly on-topic, not a scope violation. +- Acceptance: `grep -rn "5555\|5599" test/pytest` → no matches (exit 1), verified twice, including after `__pycache__` was regenerated by the test run. Whole suite: `uv run pytest -q` → `161 passed, 4 warnings in 58.71s` (single run, as my spec preferred); the two-concurrent-runs acceptance was already verified by the orchestrator (decisions.md: 161 passed / 161 passed in two concurrent `uv run pytest -q`). +- Commit hygiene: exactly one commit on `marcosfrenkel/new-param-manager`, message starts with the task number (`0.0: per-run test ports via session-scoped server_port fixture`), no push, no `orchestration/` files inside the commit. +- Vocabulary: no glossary violations in new comments, docstrings or test names ("Server", "Broadcasts" used per the glossary). No protected API touched (no `src/` change). +- Question for the user (plan text, not a code defect): the plan's "Testing" section still says "GUI tests copy the `test_gui_navigation.py` pattern (own server on a fixed port ≥ 5600, `qtbot.waitUntil`)" — D27/task 0.0 deliberately abolished fixed ports, so that line is now stale and would mislead the Phase 5 GUI test tasks; consider updating it to reference the `server_port` fixture. diff --git a/orchestration/0.0/round-0/reviewer-deepseek.md b/orchestration/0.0/round-0/reviewer-deepseek.md new file mode 100644 index 0000000..033186a --- /dev/null +++ b/orchestration/0.0/round-0/reviewer-deepseek.md @@ -0,0 +1,26 @@ +# 0.0 — reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/conftest.py:19-22 (docstring), 35 (`random.randrange(20_000, 40_000)`) +- What: The fixture docstring claims the chosen range is "deliberately outside the OS ephemeral port range", but 20000-40000 overlaps the Linux ephemeral range (which starts at 32768). +- Why: The claim is factually wrong on Linux, where 32768-40000 is inside the OS's ephemeral allocation. The real protection is the bind-check of both ports before returning, which is sound; the docstring just misstates it. +- Suggested fix: Rephrase the docstring to say the range is wide and random and that both ports are verified free by binding them before returning (drop the "outside the ephemeral range" claim, or pick a range truly below Linux's 32768 lower bound). + +### F2 — nit +- Where: test/pytest/test_server_gui.py:25-29 +- What: `_wait_until_client_points_at_server` duplicates the wait-for-client-retarget guard already in `test_gui_navigation._start_window` (test/pytest/test_gui_navigation.py:37-42). +- Why: Two copies of the same timing logic; latent drift risk if the client retarget behaviour changes. +- Suggested fix: Optional — factor into a shared helper in conftest.py, or leave; it is correct as written. + +## Notes + +- Acceptance grep is satisfied: `grep -rn "5555\|5599" test/pytest` returns nothing (remaining 5555/5599 literals elsewhere in test/ are outside test/pytest, in the not-pytest integration scripts, notebooks, a docs-verification helper comment, and test_config.py config-string assertions — all out of this task's scope and none bound a live port). +- test_apps.py now asserts the launcher default via `instrumentserver.DEFAULT_PORT`, so the 5555 literal is gone without any src/ change, as the orchestrator instructed. +- Tests I ran (whole suite): `uv run pytest -q` → `161 passed, 4 warnings in 58.80s`. The 4 warnings are a pre-existing unknown-mark warning for `pytest.mark.integration` in test_apps.py, unrelated to this change. +- Parallel acceptance check: two concurrent `uv run pytest` runs over test_basic_functionality.py + test_client_station.py both passed (16 passed each), confirming the per-session port pair prevents collision. +- The server_port fixture bind-checks both `port` and `port+1` before returning, and the shutdown client in `start_server` now uses the same server_port; `server_port` is session-scoped so every consumer in a session agrees on one port pair. +- No must-fix or should-fix findings. \ No newline at end of file diff --git a/orchestration/0.0/round-0/reviewer-qwen.md b/orchestration/0.0/round-0/reviewer-qwen.md new file mode 100644 index 0000000..fab447e --- /dev/null +++ b/orchestration/0.0/round-0/reviewer-qwen.md @@ -0,0 +1,23 @@ +# 0.0 — reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/conftest.py:18-22 (docstring) and :35 (`random.randrange(20_000, 40_000)`) +- What: The fixture docstring says the random range is "deliberately outside the OS ephemeral port range", but 32768–39999 falls inside Linux's default ephemeral range (`ip_local_port_range` 32768–60999); the claim only holds on macOS/BSD (49152–65535). +- Why: The bind-verify loop catches ports that are in use at check time, so the practical risk is small, but on Linux the top quarter of the range still carries the kernel-allocation race the docstring says the range avoids, and the stated rationale is inaccurate there. No observed failure on this machine. +- Suggested fix: Either narrow the upper bound (e.g. `random.randrange(20_000, 32_000)`), or soften the docstring to "outside the macOS ephemeral port range; the bind check covers the rest". + +## Notes + +- Scope check: commit 71aa9af touches only `AGENTS.md` and five `test/pytest/*.py` files; no `src/` change, matching the task's "No change to `src/`". +- Plan conformance (spot-checked here, plan checker owns the full call): session-scoped `server_port` fixture picking two free consecutive ports by binding both (`conftest.py:12-38`); `start_server` (`startServer(port=server_port)`), the shutdown `BaseClient(port=server_port)`, and `cli` (`Client(port=server_port)`) all use it. `test_client_station.py`: six `ClientStation(port=server_port)` and the `"5555"` assert now `str(server_port)`; `test_server_gui.py`: five `startServerGuiApplication(port=server_port)`; `test_gui_navigation.py`: `TEST_PORT = 5599` gone, port threaded through `_start_window`. AGENTS.md carries the plan's sentence verbatim under "Testing" plus a fixture-list entry. +- The new `_wait_until_client_points_at_server` (test_server_gui.py:25-29) and its twin in `test_gui_navigation.py:_start_window` (:42) are justified, not just cosmetic: `EmbeddedClient` is constructed at `DEFAULT_PORT` (server/application.py:639) and only re-targets on the `serverStarted` signal. I verified the wait is safe: the server binds the ROUTER socket *before* emitting `serverStarted` (server/core.py:213-217), so by the time the wait passes the first request is answered; ZMQ DEALER queues outgoing messages until the TCP handshake completes, and the PUB socket on `port + 1` binding a moment later (core.py:220-223) is irrelevant to these tests, whose only dependency is the request socket. +- `endswith(f":{port}")` is exact enough: the leading `:` in the suffix rules out false matches against a shorter default-port address (e.g. port 35555 vs. the initial `:5555`). +- test_apps.py: per the logged orchestrator decision, argparse-default assertions now compare against `DEFAULT_PORT` from `instrumentserver` (src `__init__.py:25`). This is a stronger invariant than the literal (it catches drift between the hardcoded `default=5555` in apps.py:128/155/167 and the package constant), and no src/ change was needed. The `"4567"` argv values in the two mocked `parameterManagerScript` tests match the file's existing style (`"9999"` in `test_server_script_passthrough_args`) and are never bound. +- No test in `test/pytest/` constructs a `Client`/`BaseClient`/`SubClient` outside the fixtures (grep-verified), so nothing silently points at 5555 anymore. `test_base.py` already used `bind_to_random_port`; `test_shortcuts.py` (`{"port": 8000}`) and `test_config.py` (`:5556`) are YAML data values, not live ports, and are outside the acceptance grep. +- `test/test_async_requests/test_client.py:9` and `demo_concurrency.py:33` still use literal 5555; the task text and its acceptance grep are scoped to `test/pytest`, so this is outside 0.0's scope (flagging for the plan checker's awareness only). +- Tests run: `uv run pytest -q` → `161 passed, 4 warnings in 58.68s` (warnings are pre-existing, e.g. unregistered `integration` mark). The two-concurrent-runs acceptance was already executed by the orchestrator and logged in orchestration/0.0/decisions.md (both 161 passed). +- Acceptance grep: `grep -rn "5555\|5599" test/pytest` → no matches (exit 1). diff --git a/orchestration/0.0/round-0/test-reviewer-deepseek.md b/orchestration/0.0/round-0/test-reviewer-deepseek.md new file mode 100644 index 0000000..39d9b47 --- /dev/null +++ b/orchestration/0.0/round-0/test-reviewer-deepseek.md @@ -0,0 +1,60 @@ +# 0.0 — test-reviewer-deepseek — round 0 + +Verdict: approve + +Single commit 71aa9af covers exactly the files the task names (conftest.py, the three +listed test modules, test_apps.py per the orchestrator decision, and AGENTS.md), with no +change to `src/`. The acceptance grep `5555|5599` over `test/pytest` (excluding +`__pycache__`) finds nothing, and I confirmed the whole suite passes (161 passed) on the +session-scoped `server_port` fixture. + +## Findings + +### F1 — nit +- Where: test/pytest/conftest.py:25-38 (`server_port` fixture, `_pair_is_free`) +- What: The fixture checks both ports are free, then closes its probe sockets and returns, + so there is a check-to-bind (TOCTOU) window before `startServer` actually binds them. +- Why: Any "pick a free port" scheme has this race; the coder mitigated the dominant + failure mode (concurrently starting sessions getting sequential adjacent ephemeral + ports) by drawing randomly from a wide non-ephemeral range (20_000-40_000), which is the + right call. Not plan-blocking — this is test infra, no `src/` change is allowed by the + task. +- Suggested fix: none required. If ever flaky in CI, hold the probe sockets open and pass + the bound file descriptors to `startServer`, but the current random-range approach is + adequate and within the task's "test-only" scope. + +### F2 — nit +- Where: test/pytest/conftest.py:12-38 +- What: No dedicated test asserts the `server_port` fixture returns a distinct usable + consecutive pair; it is only exercised end-to-end by the suite. +- Why: The task's own test criterion is "whole suite green" and names no fixture unit test; + every `start_server`/`cli`/GUI test now drives the fixture, and the parallel-collision + acceptance is a manual two-run check, so a dedicated test would be a bonus not a rule. +- Suggested fix: optional; a small test that a server started on `server_port` accepts + requests on `port` and broadcasts on `port+1` would pin the fixture contract, but it is + not required for this task. + +## Notes + +Tests run: `uv run pytest -q` in the worktree → `161 passed, 4 warnings in 59.53s` +(matches the orchestrator's two concurrent runs of 161 passed each). + +Per-test check: +- `test_client_station.py` — all six `ClientStation(port=5555)` become + `port=server_port`; the `"5555"` assert becomes `str(server_port)` — still a meaningful + assertion that the GUI widget reflects the actually-used port. Not weakened. +- `test_server_gui.py` — five `startServerGuiApplication()` calls pass `port=server_port` + and gained `_wait_until_client_points_at_server`, which waits for the embedded client's + retarget to the dynamic port before the first request. This is a strengthening (removes a + race that was invisible under the old fixed port), not a loosening. +- `test_gui_navigation.py` — `TEST_PORT = 5599` removed; `_start_window(qtbot, port)` and + each test requests `server_port`; the `addr.endswith(f":{port}")` wait is preserved. + Behaviour unchanged. +- `test_apps.py` — argparse-default asserts derive from `src` `DEFAULT_PORT` (== 5555) + instead of the literal, and two param-manager tests use a non-default `--port 4567` + sentinel; all are mocked unit tests so no live port is involved. Satisfies the + orchestrator decision to make the literal disappear without touching `src/`. +- AGENTS.md carries the required sentence verbatim plus a `server_port` bullet. + +The fixture's `port` / `port + 1` contract matches `src` (server binds `self.port`, +`broadcastPort = self.port + 1`). No test was deleted, skipped, or weakened. \ No newline at end of file diff --git a/orchestration/0.0/round-0/test-reviewer-qwen.md b/orchestration/0.0/round-0/test-reviewer-qwen.md new file mode 100644 index 0000000..9456b37 --- /dev/null +++ b/orchestration/0.0/round-0/test-reviewer-qwen.md @@ -0,0 +1,26 @@ +# 0.0 — test-reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/conftest.py:18-23 (`server_port` docstring) +- What: The docstring claims the random range 20000–40000 is "deliberately outside the OS ephemeral port range", which is true on macOS (49152–65535) but not on Linux, where the default ephemeral range starts at 32768, so the top ~7k of the range overlap. +- Why: On Linux, ports in 32768–39999 could be handed out sequentially to a concurrently starting session; the bind-verification of both `port` and `port + 1` catches ports already in use and the random draw makes an adjacent-pair hand-out extremely unlikely, so this is a docstring accuracy issue, not a behaviour one. +- Suggested fix: Soften the docstring (e.g. "outside the macOS ephemeral range; both ports are verified free regardless of platform"). + +## Notes + +- Review target: commit `71aa9af` ("0.0: per-run test ports via session-scoped server_port fixture"), the only commit in `dcac611..71aa9af`. Files: `AGENTS.md`, `test/pytest/{conftest,test_apps,test_client_station,test_gui_navigation,test_server_gui}.py`. `git diff dcac611..71aa9af -- src/` is empty, so the task's "No change to src/" rule holds. +- `server_port` (conftest.py:12-38): session-scoped, draws a random port from 20000–40000 and bind- verifies both `port` and `port + 1` (the server binds `127.0.0.1:port` for requests and `*:port+1` for the PUB broadcast socket — `server/core.py:135,152,214` — so checking `""` (0.0.0.0) for both is a correct superset check). Raises `RuntimeError` after 100 failed draws. It can fail (all draws occupied → every server module errors), and it cannot silently return a used pair. +- Named call sites, all converted: `start_server` (`startServer(port=server_port)`), the shutdown client in `start_server` (`BaseClient(port=server_port)`), `cli` (`Client(port=server_port)`), six `ClientStation(host=..., port=server_port)` in `test_client_station.py` (incl. the module-scoped `client_station` fixture) plus the `"5555"` assert (now `str(server_port)` — meaningful, since `ServerWidget` renders `client_station._port`), five `startServerGuiApplication(port=server_port)` in `test_server_gui.py`, and `TEST_PORT = 5599` removed from `test_gui_navigation.py` (now threaded through `_start_window(qtbot, port)`). +- New behaviour, well tested: `_wait_until_client_points_at_server` (test_server_gui.py:25-29), added after every `startServerGuiApplication(port=...)` call. This addresses a real new race — `EmbeddedClient` is constructed at the default port and only re-targets when the `serverStarted` signal is delivered, so without the wait the first request would go to port 5555 (a developer's live server). `test_gui_navigation.py` already had the equivalent wait pre-existing. +- `test_apps.py` (orchestrator-directed, see `orchestration/0.0/decisions.md`): the four argparse-default assertions now compare against `instrumentserver.DEFAULT_PORT` (= 5555, `src/instrumentserver/__init__.py:25`) instead of the literal — assertion strength preserved, no src change. The two `parameterManagerScript` tests use `"--port", "4567"`; those clients are `MagicMock`s, so no socket is opened — same style as the pre-existing `9999`/`9000` literals in that file and irrelevant to port collisions. +- No weakening: no `skip`/`xfail` introduced; the only removed lines are the port literals; every pre-existing assertion is intact. Other test files in `test/pytest` reach the server only through the `cli`/`param_manager` fixtures, so they inherit the dynamic port; no other fixed server-port literals remain (`test_shortcuts.py` `port: 8000` is YAML config content parsed locally, no server). +- No dedicated unit test exists for the `server_port` fixture itself (freeness, consecutiveness, session stability), but the plan names none ("Tests: whole suite green") and the fixture is self-verifying through use: a non-free pair makes the server's bind fail and every server-backed test in the session fail, and the two-concurrent-runs acceptance below exercises it. +- Tests run (mine): + - `uv run pytest -q` → `161 passed, 4 warnings in 59.41s` (the 4 warnings are pre-existing `PytestUnknownMarkWarning` for `integration`, unrelated to this commit). + - Two concurrent `uv run pytest -q` runs started at the same time: run A `161 passed, 4 warnings in 59.09s` (exit 0), run B `161 passed, 4 warnings in 58.89s` (exit 0) — the plan's second acceptance criterion, independently re-verified. + - Acceptance grep `grep -rn "5555\|5599" test/pytest` → no matches (exit 1). +- AGENTS.md: the exact required line ("Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel.") is present under "Testing", plus a `server_port` bullet in the Test Setup list. diff --git a/orchestration/RUNS.md b/orchestration/RUNS.md index 3d4289e..26500fd 100644 --- a/orchestration/RUNS.md +++ b/orchestration/RUNS.md @@ -18,3 +18,10 @@ - Workers still alive: none. Stopped because `--only 0.1` was given. - Permission prompts handled: 5 allowed (all read-only), 1 rejected (reviewer-deepseek tried `git worktree add /tmp/...`). - Process notes for the next run: (1) reviewer-deepseek's turn ended once without sending worker_done and needed a terminal nudge; (2) three reviewers running `uv run pytest` at the same time collided on fixed ports 5555/5599 and each saw one spurious failure. Consider telling reviewers to run only the task's named test file, or stagger full-suite runs. (3) opencode's bash allowlist misses read-only commands chained with `&&` or prefixed with `cd ... &&`, which caused most prompts. + +## Run 2026-09-23 — run_da269441b6ac + +- Plan: PLAN_parameter_manager_redesign.md +- Tasks: 0.0, 0.2, 0.3, 0.4, 0.5 (rest of Phase 0; 0.1 already done) +- Branch: marcosfrenkel/new-param-manager +- Starting commit: dcac611241cfbf698885d126a67e8fe11332ffc0 From 8d04b422d73bd4dde78fb53e980a54d10beab41b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 18:10:37 -0500 Subject: [PATCH 007/107] 0.2: add Broadcaster mixin and mix it into ParameterManager --- src/instrumentserver/base.py | 70 +++++++++++++++++- src/instrumentserver/params.py | 8 ++- test/pytest/test_broadcaster.py | 122 ++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 test/pytest/test_broadcaster.py diff --git a/src/instrumentserver/base.py b/src/instrumentserver/base.py index b5623ef..b66f984 100644 --- a/src/instrumentserver/base.py +++ b/src/instrumentserver/base.py @@ -1,10 +1,11 @@ import json import logging +from collections.abc import Callable from typing import Any, Tuple, Union import zmq -from .blueprints import deserialize_obj, to_dict +from .blueprints import ParameterBroadcastBluePrint, deserialize_obj, to_dict logger = logging.getLogger(__name__) @@ -71,6 +72,73 @@ def sendBroadcast(socket: "zmq.Socket", name: str, message: Any) -> None: socket.send(encode(message).encode("utf-8")) +class Broadcaster: + """ + Mixin implementing the Broadcaster contract: an instrument that emits its + own Broadcasts. + + The instrument calls :meth:`broadcast` with a + :class:`~instrumentserver.blueprints.ParameterBroadcastBluePrint`; the + Server registers itself as a sink with :meth:`add_broadcast_sink` when + the instrument joins the Station. Used standalone (no sinks registered), + :meth:`broadcast` is a no-op. + + Sinks are stored in a plain list: adding the same sink twice makes it + receive every Broadcast twice. An exception raised inside one sink is + logged and does not stop the remaining sinks. + + The annotations of the public methods are strings on purpose: the client + builds proxy methods by exec-ing the blueprint's call signature string, + and a rendered class annotation would reference a name the exec'd source + cannot resolve. Keep new blueprint-carrying annotations quoted like these. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._broadcast_sinks: list[ + Callable[[ParameterBroadcastBluePrint], None] + ] = [] + + def add_broadcast_sink( + self, fn: "Callable[[ParameterBroadcastBluePrint], None]" + ) -> None: + """ + Register ``fn`` as a sink that receives every Broadcast. + + :param fn: callable taking a ParameterBroadcastBluePrint. + """ + self._broadcast_sinks.append(fn) + + def remove_broadcast_sink( + self, fn: "Callable[[ParameterBroadcastBluePrint], None]" + ) -> None: + """ + Remove a sink that was registered with :meth:`add_broadcast_sink`. + + Removing a sink that is not registered does nothing. + """ + if fn in self._broadcast_sinks: + self._broadcast_sinks.remove(fn) + + def broadcast(self, bp: "ParameterBroadcastBluePrint") -> None: + """ + Send a Broadcast to every registered sink. + + With no sinks registered this is a no-op. An exception raised inside + one sink is logged and the remaining sinks still receive the + Broadcast. + """ + for sink in list(self._broadcast_sinks): + try: + sink(bp) + except Exception: + logger.exception( + f"Exception in broadcast sink {sink} while broadcasting " + f"'{bp.name}' / '{bp.action}'; continuing with the " + "remaining sinks." + ) + + def recvMultipart(socket: "zmq.Socket") -> Tuple[str, Any]: """ Recieves the broadcast from a broadcast message. It should consist of 2 parts: diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index ae05be5..1727e23 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -10,6 +10,7 @@ from qcodes.parameters import ParameterBase from . import serialize +from .base import Broadcaster logger = logging.getLogger(__name__) @@ -205,7 +206,7 @@ def tolist(x: Dict[str, Any]) -> List[str]: return tolist(tree) -class ParameterManager(ParameterGroup): +class ParameterManager(Broadcaster, ParameterGroup): """ A virtual instrument that acts as a manager for a collection of arbitrary parameters and groups of parameters. @@ -216,6 +217,11 @@ class ParameterManager(ParameterGroup): Parameter Group with file, profile, and (later) Type and Lock logic; its submodules are plain Parameter Groups. + It implements the Broadcaster contract, so the Server can register + itself as a broadcast sink when the Parameter Manager joins the + Station. Nothing is broadcast yet; the Lock and Type features will + emit through it. + For the parameter manager to recognize other profiles in disk, the profile filename needs to start with 'parameter_manager-' and end with '.json' with the name of the profile in the middle. diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py new file mode 100644 index 0000000..1269cdd --- /dev/null +++ b/test/pytest/test_broadcaster.py @@ -0,0 +1,122 @@ +"""Unit tests for the Broadcaster mixin (``instrumentserver.base``). + +These tests need no Server: they exercise the mixin's own behaviour — +registering and removing sinks, fanning a Broadcast out to the sinks, +tolerating an exception in one sink, and doing nothing without sinks. +The Server part of this file (the Server registering itself as a sink for +created and config-loaded instruments) is a separate task. +""" + +import logging + +from instrumentserver.base import Broadcaster +from instrumentserver.blueprints import ParameterBroadcastBluePrint +from instrumentserver.params import ParameterManager + + +def make_bp( + name: str = "pm.q01.IF", + action: str = "parameter-update", + value: float = 1.0, + unit: str = "Hz", +) -> ParameterBroadcastBluePrint: + return ParameterBroadcastBluePrint( + name=name, action=action, value=value, unit=unit + ) + + +# --------------------------------------------------------------------------- +# broadcasting behaviour of the mixin +# --------------------------------------------------------------------------- + + +def test_broadcast_without_sinks_is_a_noop(): + bc = Broadcaster() + bc.broadcast(make_bp()) # must not raise + + +def test_broadcast_reaches_the_added_sink(): + bc = Broadcaster() + bp = make_bp() + received = [] + bc.add_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp] + + +def test_broadcast_reaches_all_sinks_in_registration_order(): + bc = Broadcaster() + order = [] + bc.add_broadcast_sink(lambda bp: order.append("first")) + bc.add_broadcast_sink(lambda bp: order.append("second")) + bc.broadcast(make_bp()) + assert order == ["first", "second"] + + +def test_exception_in_one_sink_is_logged_and_others_still_run(caplog): + bc = Broadcaster() + bp = make_bp() + received = [] + + def failing_sink(bp): + raise RuntimeError("sink is broken") + + bc.add_broadcast_sink(failing_sink) + bc.add_broadcast_sink(received.append) + + with caplog.at_level(logging.ERROR, logger="instrumentserver.base"): + bc.broadcast(bp) + + assert received == [bp] + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) == 1 + assert "failing_sink" in error_records[0].getMessage() + assert error_records[0].exc_info is not None + + +def test_removed_sink_no_longer_receives_broadcasts(): + bc = Broadcaster() + received = [] + bc.add_broadcast_sink(received.append) + bc.remove_broadcast_sink(received.append) + bc.broadcast(make_bp()) + assert received == [] + + +def test_removing_a_sink_that_was_never_added_is_a_noop(): + bc = Broadcaster() + + def unknown_sink(bp): + pass + + bc.remove_broadcast_sink(unknown_sink) # must not raise + + +# --------------------------------------------------------------------------- +# Parameter Manager implements the Broadcaster contract +# (it emits nothing on its own yet; here we only prove the mixin machinery +# works on a real Parameter Manager) +# --------------------------------------------------------------------------- + + +def test_parameter_manager_is_a_broadcaster(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name="params") + assert isinstance(pm, Broadcaster) + assert callable(pm.add_broadcast_sink) + assert callable(pm.remove_broadcast_sink) + assert callable(pm.broadcast) + + +def test_parameter_manager_broadcast_reaches_sink(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name="params") + received = [] + pm.add_broadcast_sink(received.append) + bp = make_bp() + pm.broadcast(bp) + assert received == [bp] + + pm.remove_broadcast_sink(received.append) + pm.broadcast(make_bp()) + assert received == [bp] From 693d4e763d34518035de2567b2ee5dc7b9c3f4a8 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 18:40:41 -0500 Subject: [PATCH 008/107] 0.2: fix from review round 1: pin duplicate-sink delivery semantics in a unit test --- test/pytest/test_broadcaster.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py index 1269cdd..ba9cdb2 100644 --- a/test/pytest/test_broadcaster.py +++ b/test/pytest/test_broadcaster.py @@ -53,6 +53,26 @@ def test_broadcast_reaches_all_sinks_in_registration_order(): assert order == ["first", "second"] +def test_adding_the_same_sink_twice_delivers_twice(): + """Pins the documented semantics: sinks are stored in a plain list, so a + sink registered twice receives every Broadcast twice, and a single + remove leaves it registered once.""" + bc = Broadcaster() + bp = make_bp() + received = [] + bc.add_broadcast_sink(received.append) + bc.add_broadcast_sink(received.append) + + bc.broadcast(bp) + assert len(received) == 2 + assert received == [bp, bp] + + bc.remove_broadcast_sink(received.append) + bc.broadcast(bp) + assert len(received) == 3 + assert received == [bp, bp, bp] + + def test_exception_in_one_sink_is_logged_and_others_still_run(caplog): bc = Broadcaster() bp = make_bp() From 779373d1224f5880d2445ea463e444f05b00d5a2 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 18:48:45 -0500 Subject: [PATCH 009/107] 0.2: orchestration record --- PLAN_parameter_manager_redesign.md | 2 +- orchestration/0.2/decisions.md | 96 +++++++++++++++++++ orchestration/0.2/round-0/fix-list.md | 3 + .../0.2/round-0/plan-checker-deepseek.md | 76 +++++++++++++++ .../0.2/round-0/plan-checker-qwen.md | 60 ++++++++++++ .../0.2/round-0/reviewer-deepseek.md | 53 ++++++++++ orchestration/0.2/round-0/reviewer-qwen.md | 79 +++++++++++++++ .../0.2/round-0/test-reviewer-deepseek.md | 45 +++++++++ .../0.2/round-0/test-reviewer-qwen.md | 20 ++++ orchestration/0.2/round-1/fix-list.md | 3 + .../0.2/round-1/plan-checker-deepseek.md | 28 ++++++ .../0.2/round-1/plan-checker-qwen.md | 43 +++++++++ .../0.2/round-1/reviewer-deepseek.md | 42 ++++++++ orchestration/0.2/round-1/reviewer-qwen.md | 41 ++++++++ .../0.2/round-1/test-reviewer-deepseek.md | 37 +++++++ .../0.2/round-1/test-reviewer-qwen.md | 18 ++++ orchestration/RUNS.md | 13 +++ 17 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 orchestration/0.2/decisions.md create mode 100644 orchestration/0.2/round-0/fix-list.md create mode 100644 orchestration/0.2/round-0/plan-checker-deepseek.md create mode 100644 orchestration/0.2/round-0/plan-checker-qwen.md create mode 100644 orchestration/0.2/round-0/reviewer-deepseek.md create mode 100644 orchestration/0.2/round-0/reviewer-qwen.md create mode 100644 orchestration/0.2/round-0/test-reviewer-deepseek.md create mode 100644 orchestration/0.2/round-0/test-reviewer-qwen.md create mode 100644 orchestration/0.2/round-1/fix-list.md create mode 100644 orchestration/0.2/round-1/plan-checker-deepseek.md create mode 100644 orchestration/0.2/round-1/plan-checker-qwen.md create mode 100644 orchestration/0.2/round-1/reviewer-deepseek.md create mode 100644 orchestration/0.2/round-1/reviewer-qwen.md create mode 100644 orchestration/0.2/round-1/test-reviewer-deepseek.md create mode 100644 orchestration/0.2/round-1/test-reviewer-qwen.md diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 0b76a90..773fedf 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -412,7 +412,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session Tests: `test_param_manager.py` all green unchanged; add `test_submodules_are_groups` and a test with a `parameter_manager-q01.json` present in `tmp_path` proving it is **not** loaded into the `q01` submodule. -- [ ] **0.2 `Broadcaster` mixin.** In `base.py` (next to `sendBroadcast`): class +- [x] **0.2 `Broadcaster` mixin.** In `base.py` (next to `sendBroadcast`): class `Broadcaster` with `add_broadcast_sink(fn)`, `remove_broadcast_sink(fn)`, `broadcast(bp: ParameterBroadcastBluePrint)`; sinks stored in a list; exceptions in one sink are logged and do not stop the others; no sinks → no-op. `ParameterManager` inherits diff --git a/orchestration/0.2/decisions.md b/orchestration/0.2/decisions.md new file mode 100644 index 0000000..d1f437f --- /dev/null +++ b/orchestration/0.2/decisions.md @@ -0,0 +1,96 @@ +# 0.2 `Broadcaster` mixin — decisions log + +Run: run_da269441b6ac. Branch: marcosfrenkel/new-param-manager. Base commit: a1bce5e9bf8d2e3be7f6bc9c19f2ec74057e9ed5. + +## Workers + +| agent id | terminal handle | current dispatch id | +|---|---|---| +| coder | term_1bc0a2c5-d415-4f16-ab81-5cbe17bcc329 | ctx_4df4cbab12ba (task_e7ca0f53422e, first implementation) | +| reviewer-deepseek | term_76c13ad0-a01b-4883-8031-68551652ac81 | ctx_f6dcaac7bdc9 (task_747a3183842a, round 0) | +| reviewer-qwen | term_a287d55a-2281-4b23-a8ff-5cfe95d6425d | ctx_93b1c3b50dd8 (task_815cfe2d5939, round 0) | +| test-reviewer-deepseek | term_5fd75ef7-3ffc-45ad-8c44-a359fde259d0 | ctx_103f3ba66b66 (task_f4183c138862, round 0) | +| test-reviewer-qwen | term_e18b9a9e-4f01-43ab-9ba9-a28e21fb4a85 | ctx_f5b3f7a1e804 (task_eeb79235dfbd, round 0) | +| plan-checker-deepseek | term_5785f5b7-8251-459c-952e-3aa76b9ee79d | ctx_630892470a3b (task_78a0a56620eb, round 0) | +| plan-checker-qwen | term_eabc8389-b3c9-4a1a-bbc7-b37f4e78e5eb | ctx_50475d4efdd6 (task_44fa4d0caa54, round 0) | + +## Log + +- 17:52 Checkbox 0.2 set to [~]. Base a1bce5e. +- 17:52 Coder dispatched for first implementation (task_e7ca0f53422e / ctx_4df4cbab12ba). +- 17:53 Permission: coder asked python -c inspect.signature(InstrumentBase.__init__) (read-only introspection). Allowed once. +- 17:53 Permission: coder re-ran the InstrumentBase.__init__ introspection under uv run (read-only). Allowed once. +- 17:54 Permission: coder asked inspect.getsource(InstrumentBase.__init__) (read-only introspection). Allowed once. +- 17:55 Permission: coder asked dir(InstrumentBase) for broadcast-named attrs (read-only introspection). Allowed once. +- 18:08 Permission: coder asked to introspect its own new Broadcaster class (read-only). Allowed once. +- 18:08 Permission: coder asked another read-only introspection of instrumentserver.base. Allowed once. +- 18:10 Permission: coder asked ruff + mypy over its changed files (read-only lint). Allowed once. +- Coder worker_done (succeeded). Commit 8d04b42 "0.2: add Broadcaster mixin and mix it into ParameterManager"; files: src/instrumentserver/base.py, src/instrumentserver/params.py, test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit, nothing dirty outside orchestration/ and the plan. Coder retained. +- Coder reported: unquoted class annotations on the mixin's public methods broke client proxy construction (the client execs the blueprint's call-signature string), so the public annotations are quoted; docstring notes the pattern for Phase 1. Judgement calls flagged: remove of an unregistered sink is a silent no-op; duplicate add is not deduped. Left out of scope: the client-side exec fragility itself. +- Orchestrator tests: `uv run pytest -q test/pytest/test_broadcaster.py` -> 8 passed in 0.01s; `uv run pytest -q` -> 169 passed, 4 warnings in 59.24s. +- 18:13 Six reviewers dispatched for round 0 (target a1bce5e..8d04b42). +- 18:14 Permission: reviewer-deepseek asked access to ~/.agents/roles (outside repo, wrong path). REJECTED; told it the role file is at .agents/roles/reviewer.md in the worktree. +- 18:14 Permission: plan-checker-deepseek asked git log + git show --stat (read-only). Allowed once. +- 18:14 Permission: reviewer-qwen asked a python heredoc introspecting bluePrintFromMethod on ParameterManager (read-only). Allowed once. +- 18:15 Permission: test-reviewer-qwen asked python -c introspection of Broadcaster/ParameterManager (read-only). Allowed once. +- 18:15 Permission: reviewer-qwen re-ran the blueprint introspection with a tempfile working dir (read-only apart from temp files). Allowed once. +- 18:15 Permission: plan-checker-qwen asked python heredoc introspecting ParameterBroadcastBluePrint (read-only). Allowed once. +- 18:16 Permission: plan-checker-qwen asked another read-only python introspection (qcodes). Allowed once. +- 18:16 Permission: reviewer-deepseek asked ls of orchestration/0.2 (read-only). Allowed once. +- 18:17 Permission: test-reviewer-qwen asked ls of orchestration/0.2 (read-only). Allowed once. +- 18:17 Permission: plan-checker-deepseek asked inspect.signature(InstrumentBase.__init__) (read-only). Allowed once. +- 18:17 reviewer-deepseek worker_done (succeeded, approve, 1 nit). Retained. +- 18:17 test-reviewer-deepseek worker_done (succeeded, approve, 0 findings). Retained. +- 18:17 test-reviewer-qwen worker_done (succeeded, changes-needed, 1 should-fix: no test pins duplicate-add / remove-first semantics). Retained. +- 18:18 Permission: reviewer-qwen asked a python heredoc simulating unquoted annotations to verify the coder's claim (read-only, in-memory). Allowed once. +- 18:18 plan-checker-qwen worker_done (succeeded, approve, 0 findings). Retained. +- 18:18 Permission: reviewer-qwen asked ls + grep for broadcast calls in params.py (read-only). Allowed once. +- 18:19 reviewer-qwen worker_done (succeeded, approve, 2 nits; notes for 0.3 that pm.broadcast becomes wire-callable). Retained. +- 18:29 plan-checker-deepseek: turn ended on a provider 'Upstream error' with no report after ~10 min (liveness live). Nudged in its terminal to resume and report. +- 18:37 Permission: plan-checker-deepseek asked wc/rg over its own report to check for garbled text (read-only). Allowed once. +- 18:38 plan-checker-deepseek worker_done after nudge (succeeded, approve, 0 findings). Retained. All six round-0 reports present. + +## Round 0 merge (six reports: 5 approve, test-reviewer-qwen changes-needed) + +- test-reviewer-qwen F1 (should-fix): no test pins the documented "same sink twice -> delivered twice; remove drops one" semantics that 0.3 relies on. One model only; orchestrator read test_broadcaster.py: confirmed, no test adds a sink twice. KEPT (cheap, documented behaviour without a test). +- reviewer-deepseek F1 (nit): `_broadcast_sinks` annotation in `__init__` is unquoted while the docstring asks to quote blueprint-carrying annotations; `__init__` is never proxied. Not sent: nit. +- reviewer-qwen N1 (nit): no callable check in add_broadcast_sink. Not sent: nit (misuse only). +- reviewer-qwen N2 (nit): exception log line dereferences bp.name/bp.action; could raise on a non-blueprint argument. Not sent: nit (contract violation path). +- reviewer-qwen note for 0.3: `pm.broadcast` becomes wire-callable once the Server registers as a sink, so a client could inject blueprints. Not a 0.2 finding; recorded here for whoever runs 0.3 and for the user. +- Fix list: 1 item -> fix round 1. +- 18:38 Fix round 1 dispatched to the coder in its same terminal (task_411a6077e1b1 / ctx_d0532410f0b2). +- 18:40 Permission: coder asked ruff + git add test_broadcaster.py + git commit '0.2: fix from review round 1: ...' + git log, chained (all coder-allowed operations). Allowed once. +- 18:42 Coder worker_done for fix round 1 (succeeded). Commit 693d4e7 "0.2: fix from review round 1: pin duplicate-sink delivery semantics in a unit test"; only test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files. Coder retained. +- Orchestrator tests after fix 1: named file -> 9 passed in 0.01s; full suite -> ================== 170 passed, 4 warnings in 60.07s (0:01:00) ================== +- 18:42 Re-review 1 dispatched to all six reviewers in their same terminals (target 693d4e7): + +| agent id | round | dispatch (task) | +|---|---|---| +| plan-checker-qwen | re-review 1 | ctx_2f5ef64a8d64 (task_f821ca7fbd80) | +| plan-checker-deepseek | re-review 1 | ctx_c79921dd0589 (task_91bcb85c373c) | +| test-reviewer-qwen | re-review 1 | ctx_43c777c6967b (task_f8267444d33c) | +| test-reviewer-deepseek | re-review 1 | ctx_de8f62675832 (task_22215d5bf2a1) | +| reviewer-qwen | re-review 1 | ctx_c5986422eae8 (task_2be451954633) | +| reviewer-deepseek | re-review 1 | ctx_2cc507da09ef (task_c47097cb4928) | + +- 18:43 Permission: plan-checker-deepseek asked git log + git show 693d4e7 (read-only). Allowed once. +- 18:43 Re-review 1: test-reviewer-qwen approve (F1 fixed, 0 new); reviewer-qwen approve (0 new); plan-checker-qwen approve (0 new). All three retained. +- 18:43 Permission: reviewer-deepseek asked ls of orchestration/0.2 (read-only). Allowed once. +- 18:44 Permission: plan-checker-deepseek asked ls of orchestration/0.2 (read-only). Allowed once. +- 18:44 Re-review 1: test-reviewer-deepseek approve (0 new). Retained. +- 18:45 Permission: plan-checker-deepseek asked to write its report via a python3 heredoc whose target path was cut off in the prompt. REJECTED; told it to use the file-write tool on orchestration/0.2/round-1/plan-checker-deepseek.md. +- 18:46 Permission: reviewer-deepseek asked access to a garbled non-existent path outside the repo. REJECTED; told it its report exists and to send worker_done. +- 18:46 Re-review 1: reviewer-deepseek approve (0 new). Retained. + +## Round 1 merge (six re-reviews, all `approve`, 0 new findings) + +- test-reviewer-qwen F1: fixed by 693d4e7 (confirmed by test-reviewer-qwen and test-reviewer-deepseek; both say the test would fail under dedup or remove-all semantics). +- reviewer-deepseek F1, reviewer-qwen N1/N2: dropped by orchestrator (nits), reviewers acknowledge and agree. +- Fix list: EMPTY. Task goes to finish. + +## Finish + +- All seven workers released (Orca kept the externally created terminals: state retained, processAction none) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_da269441b6ac: 0 rows. +- Checkbox 0.2 set to [x]. + +**Summary.** Outcome: done. Commits: `8d04b42 0.2: add Broadcaster mixin and mix it into ParameterManager`, `693d4e7 0.2: fix from review round 1: pin duplicate-sink delivery semantics in a unit test`. Fix rounds used: 1. Tests (orchestrator run after fix 1): `uv run pytest -q test/pytest/test_broadcaster.py` -> 9 passed in 0.01s; `uv run pytest -q` -> 170 passed, 4 warnings in 60.07s. diff --git a/orchestration/0.2/round-0/fix-list.md b/orchestration/0.2/round-0/fix-list.md new file mode 100644 index 0000000..259ec6d --- /dev/null +++ b/orchestration/0.2/round-0/fix-list.md @@ -0,0 +1,3 @@ +# 0.2 — fix list after round 0 + +1. Add a unit test pinning the documented duplicate-sink semantics. Where: test/pytest/test_broadcaster.py, next to the sink registration tests. Why: the `Broadcaster` docstring (src/instrumentserver/base.py) says "adding the same sink twice makes it receive every Broadcast twice", the coder flagged no-deduplication as a judgement call, and task 0.3 builds the Server's sink registration on this contract, but no test covers it. Suggested: `test_adding_the_same_sink_twice_delivers_twice`: one `Broadcaster`, add the same sink twice, one `broadcast` -> sink called twice; one `remove_broadcast_sink` -> a further `broadcast` is delivered once. Raised by: test-reviewer-qwen F1 (should-fix); confirmed by the orchestrator by reading test_broadcaster.py (no test adds a sink twice). diff --git a/orchestration/0.2/round-0/plan-checker-deepseek.md b/orchestration/0.2/round-0/plan-checker-deepseek.md new file mode 100644 index 0000000..5552e0d --- /dev/null +++ b/orchestration/0.2/round-0/plan-checker-deepseek.md @@ -0,0 +1,76 @@ +# 0.2 — plan-checker-deepseek — round 0 + +Verdict: approve + +The commit `8d04b42` implements exactly task 0.2, meets its acceptance criteria point by +point, and stays in scope (no trace of task 0.3's server-side sink registration). No +must-fix or should-fix findings. + +## Findings + +None. + +## Notes + +### Acceptance check against the plan task line + +Plan task 0.2 (PLAN_parameter_manager_redesign.md:415-419): + +> **0.2 `Broadcaster` mixin.** In `base.py` (next to `sendBroadcast`): class +> `Broadcaster` with `add_broadcast_sink(fn)`, `remove_broadcast_sink(fn)`, +> `broadcast(bp: ParameterBroadcastBluePrint)`; sinks stored in a list; exceptions in one +> sink are logged and do not stop the others; no sinks → no-op. `ParameterManager` inherits +> it (no emissions yet). Tests: `test_broadcaster.py` unit part. + +- **next to `sendBroadcast`**: `Broadcaster` is defined at base.py:75, immediately after + `sendBroadcast` (base.py:62-72). OK. +- **`add_broadcast_sink(fn)` / `remove_broadcast_sink(fn)` / `broadcast(bp)`**: all three + present at base.py:102, base.py:112, base.py:123. +- **sinks stored in a list**: `self._broadcast_sinks: list[...] = []` at base.py:98. +- **exceptions in one sink are logged and do not stop the others**: `broadcast` wraps each + sink call in `try/except Exception` with `logger.exception()`, iterating over a copy + (`list(self._broadcast_sinks)`) so a sink removing itself during broadcast does not + corrupt the loop. +- **no sinks → no-op**: the for-loop over the empty list does nothing. +- **`ParameterManager` inherits it (no emissions yet)**: `class ParameterManager(Broadcaster, + ParameterGroup)` at params.py:209; no `broadcast` call sites were added. +- **Tests: `test_broadcaster.py` unit part**: new file test/pytest/test_broadcaster.py, 8 + unit tests; none needs a Server. + +### Vocabulary (plan rule 2) + +Names used — `Broadcaster`, `Broadcast`, `sink`, `Station`, `Parameter Manager` — match the +glossary entries in CONTEXT.md (Broadcaster, Broadcast). "Sink" is the established word in +ADR-0003 ("registers its own broadcast function as a sink") and in the plan task text. The +"instrument mutex" term from D2 / ADR-0003 is referenced correctly (the Server registers +itself as a sink) and nothing confuses it with a Lock. Casing follows plan rule 8: new +methods `add_broadcast_sink`, `remove_broadcast_sink`, `broadcast` are `snake_case`; the new +class `Broadcaster` is `CamelCase`. + +### Judgement calls flagged by the coder + +- **Removing an unregistered sink is a silent no-op**: `remove_broadcast_sink` returns + without raising when `fn` is absent. The plan task text does not specify error behaviour, + and the docstring documents the choice ("Removing a sink that is not registered does + nothing"). Reasonable; not a defect. +- **Adding the same sink twice is not deduplicated**: matches the plan's "sinks stored in a + list" and is documented in the class docstring ("adding the same sink twice makes it + receive every Broadcast twice"). Not a defect. + +### Quoted annotations (orchestrator context) + +The public method annotations are quoted strings because the client builds proxy methods by +exec-ing the blueprint's call-signature string, which cannot resolve a rendered class +annotation. This is documented in the class docstring ("Keep new blueprint-carrying +annotations quoted like these."). It does not change the runtime signatures of the mixin, +so it does not violate the contract's method names. Acceptable. + +### Scope + +The commit touches only src/instrumentserver/base.py, src/instrumentserver/params.py, and +test/pytest/test_broadcaster.py. Nothing from task 0.3 (server registers sinks) is present. +Plan rule 6 (do not widen scope) is respected. + +### Tests run + +`uv run pytest -q test/pytest/test_broadcaster.py` -> 8 passed in 0.01s. \ No newline at end of file diff --git a/orchestration/0.2/round-0/plan-checker-qwen.md b/orchestration/0.2/round-0/plan-checker-qwen.md new file mode 100644 index 0000000..1b19211 --- /dev/null +++ b/orchestration/0.2/round-0/plan-checker-qwen.md @@ -0,0 +1,60 @@ +# 0.2 — plan-checker-qwen — round 0 + +Verdict: approve + +## Findings + +None. + +## Notes + +- Acceptance, point by point, against the task line "**0.2 `Broadcaster` mixin.** In `base.py` + (next to `sendBroadcast`): class `Broadcaster` with `add_broadcast_sink(fn)`, + `remove_broadcast_sink(fn)`, `broadcast(bp: ParameterBroadcastBluePrint)`; sinks stored in + a list; exceptions in one sink are logged and do not stop the others; no sinks → no-op. + `ParameterManager` inherits it (no emissions yet). Tests: `test_broadcaster.py` unit part.": + - Placed in `src/instrumentserver/base.py:75`, directly after `sendBroadcast` (lines 62–72) ✓ + - All three methods present with the plan's names ✓ + - Sinks stored in a list (`_broadcast_sinks`, base.py:98–100) ✓ + - Per-sink try/except with `logger.exception`; iteration over a snapshot + (`list(self._broadcast_sinks)`) so a sink removing itself mid-broadcast cannot break the + others (base.py:131–139) ✓ + - No sinks → loop over empty list → no-op ✓ + - `class ParameterManager(Broadcaster, ParameterGroup)` (params.py:209); no + `self.broadcast(...)` call anywhere in `params.py`, matching "(no emissions yet)" ✓ + - `test/pytest/test_broadcaster.py` contains only the unit part (no server fixture); the + file's docstring defers the server part, which task 0.3 owns. Eight tests cover: no-op + without sinks, sink receipt, registration order, exception-logged-and-others-run + (asserts exactly one ERROR record, the sink's name in the message, and `exc_info`), + removal, removing an unregistered sink, and the mixin on a real `ParameterManager` ✓ +- Quoted annotations. The plan's literal signature is `broadcast(bp: + ParameterBroadcastBluePrint)`; the commit quotes all public-method annotations. I verified + empirically that this was necessary: `str(inspect.signature(...))` of an unquoted class + annotation renders `instrumentserver.blueprints.ParameterBroadcastBluePrint`, and + `client/proxy.py:_makeProxyMethod` (lines 355–382) execs that string with a restricted + globals dict — unquoted fails with `NameError: name 'instrumentserver' is not defined`, + quoted execs clean. This matches the plan's own fact that "any new public method on + `ParameterManager` is callable from clients with no client changes" and would otherwise + have broken rule 7 ("Do not break the existing API"). The deviation is documented in the + class docstring (base.py:90–93) and in `orchestration/0.2/decisions.md`. Not a defect. +- Judgement calls flagged by the coder, both acceptable and documented: + - Removing an unregistered sink is a silent no-op — stated in the `remove_broadcast_sink` + docstring and covered by `test_removing_a_sink_that_was_never_added_is_a_noop`. The plan + does not prescribe the opposite. + - Duplicate `add_broadcast_sink` is not deduplicated — stated in the class docstring; + consistent with "Sinks are stored in a plain list". +- Plan rules: glossary vocabulary throughout docstrings, log message and test docstring + ("Broadcast", "sink", "Server", "Station", "client", "blueprint"; no avoided terms such as + "notification"/"hook"/"callback"). Casing per rule 8 (`Broadcaster`, `snake_case` + methods). Commit message starts with `0.2:`. No CONTEXT.md change needed: "Broadcaster" is + already a glossary term (CONTEXT.md:26) and no new term was introduced. +- Scope: diff touches only `src/instrumentserver/base.py` (imports + mixin), + `src/instrumentserver/params.py` (import, base class, docstring), and the new test file. + No work from 0.3/0.4/0.5 leaked in. +- ADR-0003 consistent: opt-in contract, standalone use is a no-op, `ParameterBroadcastBluePrint` + as the payload type, and server-side registration explicitly left to task 0.3 + (`hasattr(instrument, "add_broadcast_sink")`). +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in 0.01s`. + `uv run pytest -q` (full suite) → `169 passed, 4 warnings in 58.54s` (same count and the + same pre-existing warnings as the orchestrator's run; the unknown-mark warning comes from + `test/pytest/test_apps.py`, not this commit). diff --git a/orchestration/0.2/round-0/reviewer-deepseek.md b/orchestration/0.2/round-0/reviewer-deepseek.md new file mode 100644 index 0000000..ed0d30a --- /dev/null +++ b/orchestration/0.2/round-0/reviewer-deepseek.md @@ -0,0 +1,53 @@ +# 0.2 — reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +No findings at must-fix or should-fix severity. + +### F1 — nit +- Where: src/instrumentserver/base.py:98-100 +- What: `__init__`'s `_broadcast_sinks` annotation is an unquoted + `Callable[[ParameterBroadcastBluePrint], None]`, while the class docstring + (lines 90-93) instructs that blueprint-carrying annotations be quoted + because the client proxy execs the call-signature string. +- Why: Not a bug — `__init__` is never a proxied method (only public + `add_broadcast_sink` / `remove_broadcast_sink` / `broadcast` appear in the + blueprint, and all three are correctly quoted), so no exec path can trip on + it. It is only inconsistent with the stated convention. +- Suggested fix: Either quote it for consistency, or note in the docstring + that the quoting rule applies to proxied (blueprint-listed) methods only. + No behavioural change either way. + +## Notes + +- Commit: 8d04b42 `0.2: add Broadcaster mixin and mix it into ParameterManager` + (the only commit in `a1bce5e..8d04b42`). +- Verified in my lane: + - `broadcast` iterates `list(self._broadcast_sinks)` (a copy), so a sink + registering/removing sinks mid-broadcast cannot raise + `RuntimeError: list changed during iteration`; the plan's "exceptions in + one sink are logged and do not stop the others" is satisfied and the + remaining sinks still run. + - `remove_broadcast_sink` uses `in` + `list.remove`, so removing an + unregistered sink is a silent no-op and removing one of two equal sinks + deletes only the first occurrence — consistent with the class docstring's + documented no-dedup behaviour (the coder's flagged judgement calls). Not a + defect against the plan, which only says "sinks stored in a list". + - MRO check: `ParameterManager(Broadcaster, ParameterGroup)` — `__init__` + at params.py:233 calls `super().__init__(name)` which lands in + `Broadcaster.__init__(*args, **kwargs)`, initialising `_broadcast_sinks` + then forwarding to `ParameterGroup.__init__`. A real `ParameterManager` + constructs correctly (proven by the passing test). Submodules stay plain + `ParameterGroup` (no Broadcaster), matching D15. + - The quoted-annotation claim is sound: proxy.py:376-382 builds and `exec`s + the method source with `globs` = `wrap/qcodes/collections` plus + `from typing import *`; an unquoted class annotation would render a name + (`ParameterBroadcastBluePrint`) that the exec'd source cannot resolve. + - New import `from collections.abc import Callable` and + `ParameterBroadcastBluePrint` in base.py do not shadow or clash with + existing names; no caller breakage found (no existing API removed or + changed). +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → 8 passed. + Did not run the full suite (no need for this review; unit part green). \ No newline at end of file diff --git a/orchestration/0.2/round-0/reviewer-qwen.md b/orchestration/0.2/round-0/reviewer-qwen.md new file mode 100644 index 0000000..ab161a3 --- /dev/null +++ b/orchestration/0.2/round-0/reviewer-qwen.md @@ -0,0 +1,79 @@ +# 0.2 — reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +### N1 — nit +- Where: src/instrumentserver/base.py:102-110 +- What: `add_broadcast_sink` does not check that `fn` is callable; a non-callable + "sink" stays registered and raises `TypeError` on every subsequent + `broadcast`, which is then swallowed and logged as a sink exception. +- Why: The contract types the argument as a callable, so this only matters on + misuse, but the failure mode is a permanently failing sink that hides a + registration-time mistake behind per-Broadcast log noise. +- Suggested fix: One-line fail-fast at registration: `if not callable(fn): raise + TypeError(...)`. Optional; behaviour otherwise matches the plan's contract. + +### N2 — nit +- Where: src/instrumentserver/base.py:134-138 +- What: The exception log line dereferences `bp.name` / `bp.action` while + handling a sink exception. +- Why: If a caller passes a non-`ParameterBroadcastBluePrint` (contract + violation) and a sink raises, the f-string itself raises `AttributeError`, + which escapes `broadcast` instead of being logged. Extremely narrow edge of + an already-invalid call, so preference only. +- Suggested fix: Log via `getattr(bp, "name", bp)` / `getattr(bp, "action", + bp)` or just `bp!r`, so the logging path cannot raise. + +## Notes + +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in + 0.01s`; full suite `uv run pytest -q` → `169 passed, 4 warnings in 58.30s` + (same result the orchestrator recorded in decisions.md). +- Plan conformance of the shape: all three method names and the + `broadcast(bp: ParameterBroadcastBluePrint)` signature match the task; sinks + are a list; no sinks → no-op; one sink's exception is logged (ERROR with + traceback, which satisfies "logged") and does not stop the others + (`test_exception_in_one_sink_is_logged_and_others_still_run` verifies + order, level, and that later sinks still receive); `ParameterManager` + inherits the mixin (MRO `ParameterManager → Broadcaster → ParameterGroup → + InstrumentBase`, valid) and emits nothing yet (no `self.broadcast` calls in + `params.py`). The class sits in `base.py` directly after `sendBroadcast`, + as the plan says. +- The coder's reported judgement call about quoted annotations is correct and + empirically verified, not a style choice: I reproduced the client pipeline. + `bluePrintFromInstrumentModule` (`blueprints.py:334-344`) picks up all three + inherited public methods via `dir(ins)`, and `str(inspect.signature(...))` + of an *unquoted* annotation renders the dotted qualified name + (`instrumentserver.blueprints.ParameterBroadcastBluePrint`), which the + client's `exec` in `_makeProxyMethod` (`client/proxy.py:376-382`, globs + limited to `wrap`/`qcodes`/`collections` + `typing`) cannot resolve → + `NameError` during proxy construction, i.e. every client that builds a + ParameterManager proxy would break. Quoted annotations render as string + literals and exec cleanly (verified for all three methods). The class + docstring paragraph documenting this for Phase 1 is a useful guardrail. +- The two flagged judgement calls (remove of an unregistered sink is a silent + no-op; duplicate add is not deduplicated) are consistent with the plan, + documented in the docstring and the `remove` docstring respectively, and + covered by tests. I agree with both; one note on coherence: with duplicates, + one `remove_broadcast_sink` removes one registration, which matches the + "added twice → receives twice" semantics. +- Observation for task 0.3 (not a defect of this commit): once the Server + registers itself as a sink, `pm.broadcast` is callable from any remote + client (the plan, line 158, makes every new public method proxyable, and + call args are JSON-decoded with `deserialize_obj`, so a dict carrying + `_class_type` becomes a real `ParameterBroadcastBluePrint`). A client could + then inject arbitrary blueprints into the PUB broadcast stream. ` + add_broadcast_sink` is effectively not operable over the wire (a callable + is not JSON-serialisable), so the exposure is `broadcast` only. This is + inherent to the plan's "public methods are proxyable" design; worth a + sentence in the 0.3 review or the plan checker's lane. +- No import-cycle risk introduced: `params.py → base.py → blueprints.py` and + `base.py → blueprints.py` already existed in the parent commit; full suite + green confirms. +- Commit hygiene: exactly one commit `8d04b42` in the range, message prefixed + `0.2:`, only the three expected files touched; no test is vacuous — each + would fail if its behaviour regressed (no-op without sinks, delivery, + registration order, exception isolation + logging, removal, unregistered + removal, mixin inheritance on a real `ParameterManager`). diff --git a/orchestration/0.2/round-0/test-reviewer-deepseek.md b/orchestration/0.2/round-0/test-reviewer-deepseek.md new file mode 100644 index 0000000..1439da3 --- /dev/null +++ b/orchestration/0.2/round-0/test-reviewer-deepseek.md @@ -0,0 +1,45 @@ +# 0.2 — test-reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +No findings. The task's named test file is present, all tests are unit tests at the +correct layer (no server), and each required behaviour of the `Broadcaster` mixin is +covered by a test that would genuinely fail if the behaviour were broken. + +Per-test mapping: + +- `test_broadcast_without_sinks_is_a_noop` — proves `broadcast` with no sinks neither + raises nor needs sinks (plan: "no sinks → no-op"). Would fail if it raised. +- `test_broadcast_reaches_the_added_sink` — proves the blueprint object is delivered to a + removed-sink-after-add sink intact (identity check). Would fail if the sink received + nothing or a mutated value. +- `test_broadcast_reaches_all_sinks_in_registration_order` — proves fan-out to all sinks in + registration order (plan: "sinks stored in a list"; order corresponds to list order). + Would fail if a sink were skipped or order scrambled. +- `test_exception_in_one_sink_is_logged_and_others_still_run` — proves a raising sink is + logged (`caplog`, ERROR level, exc_info set, sink name in the message) and does not stop + the remaining sink (plan: "exceptions in one sink are logged and do not stop the others"). + Would fail if the exception propagated or the second sink were skipped. +- `test_removed_sink_no_longer_receives_broadcasts` — proves `remove_broadcast_sink` + detaches a sink. Would fail if removal did nothing. +- `test_removing_a_sink_that_was_never_added_is_a_noop` — covers the coder's flagged + judgement call (silent no-op on removing an unregistered sink); asserts no raise. Would + fail if removing an unknown sink raised. +- `test_parameter_manager_is_a_broadcaster` — proves `ParameterManager` inherits the mixin + and exposes all three public methods. Would fail if the class did not mix it in. +- `test_parameter_manager_broadcast_reaches_sink` — proves the mixin machinery works on a + real `ParameterManager` (add, broadcast, remove), exercising the `Broadcaster.__init__` + chained-`super().__init__` path. Would fail if the mixin init broke ParameterManager + construction or the sink were not called. + +## Notes + +- Commit range `a1bce5e..8d04b42` is exactly one commit (8d04b42). +- No existing tests weakened, deleted or skipped. No parametrised-away assertions. +- Behaviour added: quoted annotations on public methods (reason flagged by the coder) and + the judged no-op/dedup points. The no-op-remove is tested; duplicate-add-not-deduped is + documented in the docstring and is not a required behaviour in the plan, so no test is + mandated for it. +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in 0.01s`. \ No newline at end of file diff --git a/orchestration/0.2/round-0/test-reviewer-qwen.md b/orchestration/0.2/round-0/test-reviewer-qwen.md new file mode 100644 index 0000000..d40f61b --- /dev/null +++ b/orchestration/0.2/round-0/test-reviewer-qwen.md @@ -0,0 +1,20 @@ +# 0.2 — test-reviewer-qwen — round 0 + +Verdict: changes-needed + +## Findings + +### F1 — should-fix +- Where: test/pytest/test_broadcaster.py (gap; belongs next to the sink registration tests, lines ~27–58) +- What: The commit documents, in the `Broadcaster` class docstring, that "adding the same sink twice makes it receive every Broadcast twice", and the coder flagged no-deduplication as an explicit judgement call, but no test pins this behaviour down. +- Why: The plan says "sinks stored in a list" (task 0.2), and task 0.3 makes the Server register itself as a sink per instrument — a double registration would silently double every wire broadcast to all GUIs and Listeners. Without a test, a future "fix" that deduplicates (or a change to `remove_broadcast_sink`, which removes only the first matching entry) would pass CI while changing the contract the next task builds on. +- Suggested fix: Add `test_adding_the_same_sink_twice_delivers_twice`: setup — one `Broadcaster`, add the same sink (e.g. `received.append`) twice; action — one `broadcast(make_bp())`; expected — the sink receives the blueprint twice (`len(received) == 2`), and one `remove_broadcast_sink` call leaves one active sink (a second broadcast is still delivered once). + +## Notes + +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in 0.01s`. Full suite: `uv run pytest -q` → `169 passed, 4 warnings in 58.55s`. +- Per-test check (all 8 tests in the new file): each would fail if the feature were broken — no-sink no-op fails if empty fan-out raises; single-sink/reach tests fail if `broadcast` skips sinks; ordering test fails if fan-out order changes; the exception test fails both if the exception is not logged (asserts exactly one ERROR record on `instrumentserver.base` with `exc_info` set, and that the message names the failing sink) and if it stops the remaining sinks (`received == [bp]`); removal tests fail if removal is a no-op. `test_parameter_manager_is_a_broadcaster` / `test_parameter_manager_broadcast_reaches_sink` fail if the mixin is not mixed into `ParameterManager` (also exercises the `Broadcaster.__init__` → `ParameterGroup.__init__` MRO chain on a real construction). +- Layer and naming: all tests are server-free unit tests, matching the task's "unit part" of `test_broadcaster.py` (the server part is task 0.3). Names use the plan/CONTEXT vocabulary (Broadcast, sink, no-op, Broadcaster); the module docstring correctly defers the server part to a separate task. +- Quoted-annotation fix is covered indirectly: the three new public methods appear in `dir(ParameterManager)` and are not shadowed by the qcodes base class, so `bluePrintFromInstrumentModule` includes them and every test that builds a Parameter Manager proxy (`param_manager` fixture in `test_param_manager.py`, `test_basic_functionality.py`) execs the new signature strings in `_makeProxyMethod`. I verified `str(inspect.signature(...))` renders the quoted annotations as string literals, which the client's `exec` accepts; the full-suite pass confirms it. An explicit proxy-level test for `broadcast` lands with task 0.3, as planned. +- Considered, not findings: sink removed mid-broadcast (the implementation iterates a copy of the list; the plan does not require this edge) and passing a wrong-typed blueprint to `broadcast` (plan does not require it). "No emissions yet" on `ParameterManager` is a scope rule, not a named test — left to the plan checker. +- No pre-existing tests were weakened, deleted or skipped by this commit; it adds one new test file only. diff --git a/orchestration/0.2/round-1/fix-list.md b/orchestration/0.2/round-1/fix-list.md new file mode 100644 index 0000000..14409c7 --- /dev/null +++ b/orchestration/0.2/round-1/fix-list.md @@ -0,0 +1,3 @@ +# 0.2 — fix list after round 1 + +Empty. All six reviewers returned `approve` on fix commit 693d4e7; the one round-0 should-fix (test-reviewer-qwen F1) is marked fixed, no new findings. diff --git a/orchestration/0.2/round-1/plan-checker-deepseek.md b/orchestration/0.2/round-1/plan-checker-deepseek.md new file mode 100644 index 0000000..fb53dfe --- /dev/null +++ b/orchestration/0.2/round-1/plan-checker-deepseek.md @@ -0,0 +1,28 @@ +# 0.2 - plan-checker-deepseek - round 1 + +Verdict: approve + +The fix commit 693d4e7 adds exactly one unit test pinning the documented +duplicate-sink semantics. It changes no src code, stays in scope, and does +not break or weaken anything in my focus area. + +## Previous findings + +My round-0 report had no findings (verdict approve). There is nothing of +mine to mark as fixed, not fixed, or dropped. The single fix-list item was +raised by test-reviewer-qwen, not by me. + +## New findings from the fix + +None. The added test is correct, matches the class docstring, and can fail +if the documented semantics were ever changed. + +## Notes + +The test asserts that adding the same sink twice delivers two copies, and +that a single remove drops one occurrence, leaving one delivery. That is +exactly what the docstring promises and what task 0.3 relies on. + +Vocabulary and casing follow the glossary and plan rule 8. + +Run: uv run pytest -q test/pytest/test_broadcaster.py -> 9 passed in 0.01s. \ No newline at end of file diff --git a/orchestration/0.2/round-1/plan-checker-qwen.md b/orchestration/0.2/round-1/plan-checker-qwen.md new file mode 100644 index 0000000..5d1fe3c --- /dev/null +++ b/orchestration/0.2/round-1/plan-checker-qwen.md @@ -0,0 +1,43 @@ +# 0.2 — plan-checker-qwen — round 1 + +Verdict: approve + +## Previous findings + +My round-0 report had **zero findings** (verdict: approve, 0 must-fix / 0 should-fix / 0 nit), so there +is nothing to mark fixed / not fixed / dropped. The single fix-list item +(`orchestration/0.2/round-0/fix-list.md`, item 1) was raised by test-reviewer-qwen, not by me; it is +implemented by the new test `test_adding_the_same_sink_twice_delivers_twice`. + +## Findings + +None. + +## Fix commit check (693d4e7) + +- Scope: adds exactly one test to `test/pytest/test_broadcaster.py`; no `src/` change, no other file. + Nothing from task 0.3 or later leaked in. +- Plan consistency: pins the behaviour the round-0 docstring already committed to + (`src/instrumentserver/base.py`: "adding the same sink twice makes it receive every Broadcast + twice") and plan task 0.2's "sinks stored in a list": add same sink twice → one `broadcast` + delivers twice (`assert received == [bp, bp]`), one `remove_broadcast_sink` → one remaining + registration (`assert received == [bp, bp, bp]` after a further broadcast). Matches + `list.append` / first-occurrence `list.remove` semantics exactly. +- Test quality: it can fail — a deduplicating store would fail the first assertion; removing all + occurrences would fail the second. Not a vacuous test. +- Vocabulary: test name and docstring use "sink" / "Broadcast" only; no glossary violations. +- Plan rules: test lives in the plan-named file `test_broadcaster.py`; commit message starts with + the task number (`0.2: fix from review round 1: ...`), per session protocol step 6 + ("each round of review fixes is its own commit"). +- No weakening: the fix is purely additive; round-0 behaviour (mixin semantics, no emissions, + quoted annotations) is untouched. + +## Notes + +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s` + (was 8 in round 0; +1 from the fix commit). Full-suite run not needed this round; the + orchestrator's post-fix run (`decisions.md` log, 18:42) recorded 170 passed, 4 pre-existing + warnings. +- The round-0 notes in my previous report (quoted-annotation deviation justified and verified; + both flagged judgement calls documented) still stand; this round pins the duplicate-sink + judgement call in a test as well. diff --git a/orchestration/0.2/round-1/reviewer-deepseek.md b/orchestration/0.2/round-1/reviewer-deepseek.md new file mode 100644 index 0000000..e073c33 --- /dev/null +++ b/orchestration/0.2/round-1/reviewer-deepseek.md @@ -0,0 +1,42 @@ +# 0.2 — reviewer-deepseek — round 1 (re-review) + +Verdict: approve + +## Previous findings + +- F1 (nit) — unquoted `_broadcast_sinks` annotation in `__init__` + (src/instrumentserver/base.py:98-100) inconsistent with the class + docstring's quoting convention. **Dropped by orchestrator** (decisions.md + line 56, "Not sent: nit"). Not fixed in 693d4e7, which touches only the + test file. I agree with the drop: `__init__` is never a proxied method, so + the inconsistency is cosmetic and not worth a fix round. + +No other findings in my round-0 report. + +## Regression check (did 693d4e7 break or weaken anything in my lane?) + +The fix commit adds a single unit test, +`test_adding_the_same_sink_twice_delivers_twice`, and touches no `src/` +file. There is no new production code path to regress. The new test pins the +already-documented duplicate-sink semantics and matches the existing +`Broadcaster` implementation exactly: `add_broadcast_sink` appends +unconditionally (no dedup), `broadcast` iterates `list(self._broadcast_sinks)` +(a copy), and `remove_broadcast_sink` uses `in` + `list.remove`, deleting the +first occurrence. The test's expectations (`[bp, bp]` after one broadcast of +a duplicated sink; `[bp, bp, bp]` after a single remove and a further +broadcast) are consistent with that code. The mixin's correctness, +exception-isolation and no-sink no-op behaviour are unweakened. + +## Findings + +No new findings at any severity. + +## Notes + +- The fix round was driven by test-reviewer-qwen's F1 (a test-coverage gap: no + test adds the same sink twice), which is squarely in the test reviewer's + lane, not mine. It does not introduce any general-review concern. +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → + 9 passed in 0.01s. I ran the named file only (a full-suite run is not + needed for this re-review; the orchestrator separately confirmed 170 + passed in full after the fix). \ No newline at end of file diff --git a/orchestration/0.2/round-1/reviewer-qwen.md b/orchestration/0.2/round-1/reviewer-qwen.md new file mode 100644 index 0000000..ccfd451 --- /dev/null +++ b/orchestration/0.2/round-1/reviewer-qwen.md @@ -0,0 +1,41 @@ +# 0.2 — reviewer-qwen — round 1 + +Verdict: approve + +## Previous findings + +- N1 (nit) — no `callable` check in `add_broadcast_sink` (src/instrumentserver/base.py:102-110): **dropped by orchestrator.** Decisions log "Round 0 merge": "Not sent: nit (misuse only)." Not in the fix list; base.py is unchanged in 693d4e7. Accepted. +- N2 (nit) — exception log line dereferences `bp.name`/`bp.action` and could raise on a non-blueprint argument (src/instrumentserver/base.py:134-138): **dropped by orchestrator.** "Not sent: nit (contract violation path)." Not in the fix list; unchanged. Accepted. + +Both were nits; neither needed fixing for this task. No action expected. + +## Fix commit review (693d4e7) + +The commit adds exactly one test, `test_adding_the_same_sink_twice_delivers_twice` +(test/pytest/test_broadcaster.py:56-73), and nothing in src/ changes. + +- It implements the round-0 fix-list item (raised by test-reviewer-qwen) exactly as + suggested: one `Broadcaster`, the same sink added twice, one `broadcast` → sink + called twice (`received == [bp, bp]`), one `remove_broadcast_sink` → a further + `broadcast` delivered once (`received == [bp, bp, bp]`). +- It pins the semantics documented in the `Broadcaster` class docstring + ("adding the same sink twice makes it receive every Broadcast twice") that task 0.3 + builds on, and is not vacuous: a deduplicating implementation fails the first + assertion; a `remove` that drops all equal sinks fails the second. +- Correctness note: `received.append` is a bound method, and the two registered + references compare equal, so `list.remove` (used in `remove_broadcast_sink`, + base.py:119-121) removes exactly one registration — the test matches the + implementation's `in`/`remove` semantics. +- Placement is next to the other sink-registration tests, as the fix list asked; + name and docstring use glossary terms ("Broadcast", "sink"). + +## New findings + +None. The commit touches only the test file and adds a passing, non-vacuous test; +no behaviour in my focus area changed or weakened. + +## Notes + +- `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s` (was 8; + matches the orchestrator's post-fix run in decisions.md, full suite 170 passed). +- Counts: 0 must-fix, 0 should-fix, 0 new nits. diff --git a/orchestration/0.2/round-1/test-reviewer-deepseek.md b/orchestration/0.2/round-1/test-reviewer-deepseek.md new file mode 100644 index 0000000..639c352 --- /dev/null +++ b/orchestration/0.2/round-1/test-reviewer-deepseek.md @@ -0,0 +1,37 @@ +# 0.2 — test-reviewer-deepseek — round 1 + +Verdict: approve + +## Previous findings + +My round-0 report had **no findings**: it approved with 0 must-fix, 0 should-fix, 0 nit. +Nothing of mine was carried into `fix-list.md`. The single fix-list item (should-fix, raised +by test-reviewer-qwen, confirmed by the orchestrator) asked for a unit test pinning the +documented duplicate-sink semantics. That item is addressed by commit 693d4e7: + +- New test `test_adding_the_same_sink_twice_delivers_twice` — pins that adding the same + sink twice delivers each Broadcast twice, and a single remove leaves it registered once. + Status: **fixed** (the fix-list item, added by the coder; not my own finding but in scope + of this review's focus area). + +## Findings + +No findings. + +- The added test genuinely discriminates: if `add_broadcast_sink` ever deduplicated, the + `len(received) == 2` / `received == [bp, bp]` assertions on the first `broadcast` would + fail (would be 1 delivery). If `remove_broadcast_sink` removed every occurrence instead of + the first, the final `received == [bp, bp, bp]` would fail (would be `[bp, bp]`). +- Right layer: pure unit test, no server, consistent with the task's "test_broadcaster.py + unit part". +- Name and assertions use the plan's vocabulary ("sink", "Broadcast") and match the recommended + fix exactly. +- The commit touches only `test/pytest/test_broadcaster.py`; no src/ change, and no existing + test was modified, deleted, weakened, or skipped. + +## Notes + +- Fix commit 693d4e7 adds one test; the previous suite count was 8, now 9 tests. +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s` + (matches the orchestrator's full-suite line: 170 passed after this fix). +- New findings caused by the fix commit: none. \ No newline at end of file diff --git a/orchestration/0.2/round-1/test-reviewer-qwen.md b/orchestration/0.2/round-1/test-reviewer-qwen.md new file mode 100644 index 0000000..66b68c4 --- /dev/null +++ b/orchestration/0.2/round-1/test-reviewer-qwen.md @@ -0,0 +1,18 @@ +# 0.2 — test-reviewer-qwen — round 1 + +Verdict: approve + +## Previous findings + +- F1 (should-fix, round 0): no test pins the documented duplicate-sink semantics ("adding the same sink twice makes it receive every Broadcast twice"; `remove_broadcast_sink` drops only the first matching entry). + **Status: fixed.** Fix commit `693d4e7` adds `test_adding_the_same_sink_twice_delivers_twice` (test/pytest/test_broadcaster.py:56), placed next to the sink registration tests. Setup: one `Broadcaster`, the same sink (`received.append`) added twice; action: one `broadcast(bp)`; expected: `received == [bp, bp]`. Then one `remove_broadcast_sink` and a second broadcast; expected: `received == [bp, bp, bp]` (one remaining registration delivers once). This matches the setup/action/expected I proposed in round 0, and it would fail if `add_broadcast_sink` deduplicated, if removal cleared both registrations, or if removal was a no-op. No other findings were open; nothing was dropped by the orchestrator. + +## Findings + +None. (0 must-fix, 0 should-fix, 0 nit.) + +## Notes + +- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s`. Full-suite run not required for this re-review (orchestrator log records `170 passed, 4 warnings in 60.07s` after the fix). +- The fix commit is an insert-only change to test/pytest/test_broadcaster.py (verified with `git show 693d4e7`): one new test function added between `test_broadcast_reaches_all_sinks_in_registration_order` and `test_exception_in_one_sink_is_logged_and_others_still_run`; the other eight tests and all src/ files are untouched, so nothing in my focus area was weakened or broken. +- New test layer and naming: server-free unit test, in plan vocabulary (sink, Broadcast, deliver); the docstring states the pinned contract explicitly. No new behaviour or gap introduced. diff --git a/orchestration/RUNS.md b/orchestration/RUNS.md index 26500fd..a08a564 100644 --- a/orchestration/RUNS.md +++ b/orchestration/RUNS.md @@ -25,3 +25,16 @@ - Tasks: 0.0, 0.2, 0.3, 0.4, 0.5 (rest of Phase 0; 0.1 already done) - Branch: marcosfrenkel/new-param-manager - Starting commit: dcac611241cfbf698885d126a67e8fe11332ffc0 + +### Report + +| Task | Outcome | Commits | Fix rounds | Final tests | +|---|---|---|---|---| +| 0.0 | done | `71aa9af 0.0: per-run test ports via session-scoped server_port fixture` | 0 | full suite 161 passed, 4 warnings (two concurrent runs both green) | +| 0.2 | done | `8d04b42 0.2: add Broadcaster mixin and mix it into ParameterManager`, `693d4e7 0.2: fix from review round 1: pin duplicate-sink delivery semantics in a unit test` | 1 | named file 9 passed; full suite 170 passed, 4 warnings | + +- Stopped after 0.2 at the user's request (user asked mid-run not to start 0.3). Remaining Phase 0 tasks: 0.3, 0.4, 0.5. +- Open questions for the user: (1) plan-checker-qwen: the plan's "Testing" section still says GUI tests use "own server on a fixed port >= 5600", which D27 / task 0.0 made stale; the plan text should be updated. (2) reviewer-qwen note for 0.3: once the Server registers itself as a sink, `ParameterManager.broadcast` (a public method) becomes callable over the wire, so any client could inject arbitrary Broadcasts; consider whether 0.3 should address that or whether it is accepted. +- Workers still alive: none. +- Permission prompts: ~45 handled; all read-only or coder-allowed edits/commits allowed once, 3 rejected (reviewer-deepseek asked for ~/.agents/roles and a garbled path outside the repo; plan-checker-deepseek tried to write its report via a python heredoc whose target was not visible). +- Process notes: (1) deepseek reviewers stalled three times (one garbled-output degeneration, one provider "Upstream error", one idle after concluding); a terminal nudge recovered each. (2) The 0.0 fixture removed the port collisions seen in the pilot run; reviewers ran the suite in parallel with no spurious failures. (3) Nits not sent but worth folding into a later task touching conftest.py: the server_port docstring's "outside the OS ephemeral range" claim is false on Linux. From 0fbbddf9ba0410fbbd429f5f348067726591d4d8 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 21:04:04 -0500 Subject: [PATCH 010/107] Orchestration: widen read-only and coder edit permissions, no bytecode in workers - opencode.json: allow read-only shell tools for all agents (find without -delete/-exec, git grep/ls-files, ps, lsof, sort, diff, jq, ...); allow sed -i / perl -pi for the coder, deny them for reviewers. - Role files: 'Reading code' section pointing at .venv site-packages. - ROSTER.md: launch workers with PYTHONDONTWRITEBYTECODE=1; permission lists. - Plan: 0.0 acceptance uses git grep; Testing conventions use server_port (D27). Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/roles/ROSTER.md | 20 +-- .agents/roles/coder.md | 9 ++ .agents/roles/plan-checker.md | 9 ++ .agents/roles/reviewer.md | 9 ++ .agents/roles/test-reviewer.md | 9 ++ PLAN_parameter_manager_redesign.md | 6 +- opencode.json | 210 ++++++++++++++++++++++++++++- 7 files changed, 253 insertions(+), 19 deletions(-) diff --git a/.agents/roles/ROSTER.md b/.agents/roles/ROSTER.md index da2c414..1abd631 100644 --- a/.agents/roles/ROSTER.md +++ b/.agents/roles/ROSTER.md @@ -9,13 +9,13 @@ instructions and work with any coding agent. | Id | Role file | Runner | Model | Launch command | Role file loaded by runner? | |---|---|---|---|---|---| -| `coder` | `coder.md` | opencode | lumen/glm-5.3-flash | `opencode --agent coder` | yes | -| `reviewer-deepseek` | `reviewer.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent reviewer-deepseek` | yes | -| `reviewer-qwen` | `reviewer.md` | opencode | lumen/qwen3.8-27b | `opencode --agent reviewer-qwen` | yes | -| `test-reviewer-deepseek` | `test-reviewer.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent test-reviewer-deepseek` | yes | -| `test-reviewer-qwen` | `test-reviewer.md` | opencode | lumen/qwen3.8-27b | `opencode --agent test-reviewer-qwen` | yes | -| `plan-checker-deepseek` | `plan-checker.md` | opencode | lumen/deepseek-v4-flash | `opencode --agent plan-checker-deepseek` | yes | -| `plan-checker-qwen` | `plan-checker.md` | opencode | lumen/qwen3.8-27b | `opencode --agent plan-checker-qwen` | yes | +| `coder` | `coder.md` | opencode | lumen/glm-5.3-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent coder` | yes | +| `reviewer-deepseek` | `reviewer.md` | opencode | lumen/deepseek-v4-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent reviewer-deepseek` | yes | +| `reviewer-qwen` | `reviewer.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent reviewer-qwen` | yes | +| `test-reviewer-deepseek` | `test-reviewer.md` | opencode | lumen/deepseek-v4-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent test-reviewer-deepseek` | yes | +| `test-reviewer-qwen` | `test-reviewer.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent test-reviewer-qwen` | yes | +| `plan-checker-deepseek` | `plan-checker.md` | opencode | lumen/deepseek-v4-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent plan-checker-deepseek` | yes | +| `plan-checker-qwen` | `plan-checker.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent plan-checker-qwen` | yes | **Last column.** "yes" means the runner loads the role file itself as standing instructions. "no" means the orchestrator must paste the role file's full text at the top of @@ -27,17 +27,17 @@ Whatever runner fills a role, set up its permission system to match these three opencode they live in `opencode.json`. **Always allowed (all roles):** reading and searching files; `git status`, `diff`, `log`, -`show`, `blame`, `rev-parse`, `branch --show-current`; `cd`, `pwd`, `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`, `sed -n`, `lsof -nP -i...`; +`show`, `blame`, `rev-parse`, `branch --show-current`; `cd`, `pwd`, `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`, `sed -n`, `lsof`, `ps`, `find` (not with `-delete`/`-exec`), `git grep`, `git ls-files`, `sort`, `uniq`, `cut`, `diff`, `jq`, `echo`, and similar read-only tools; `uv run pytest ...`; the `orca orchestration` worker commands (`check`, `send`, `ask`) that Orca's preamble tells workers to run. -**Coder also:** editing files; `git add `; `git commit -m ...`. +**Coder also:** editing files, including in-place shell edits (`sed -i`, `perl -pi`, `perl -i`); `git add `; `git commit -m ...`. **Reviewers also:** creating or editing files under `orchestration/` (including `mkdir -p` there), and nothing else. **Always denied (all roles):** `git push`, `rebase`, `reset`, `commit --amend`, `stash`, `checkout`, `switch`, `branch -d/-D`, `clean`; `git add -A`, `git add .`, `git add --all`. -**Reviewers also:** editing anything outside `orchestration/`, `git add`, `git commit`. +**Reviewers also:** editing anything outside `orchestration/`, in-place shell edits (`sed -i`, `perl -pi`, `perl -i`), `git add`, `git commit`. **Everything else: ask.** The question goes to whoever watches the agent: the orchestrator, which decides per `SKILL.md` "Permission prompts". diff --git a/.agents/roles/coder.md b/.agents/roles/coder.md index e7e71e6..1230fef 100644 --- a/.agents/roles/coder.md +++ b/.agents/roles/coder.md @@ -20,6 +20,15 @@ the only agent that edits files. 7. Report back through Orca as your spec's preamble describes, with outcome, commit hash, test summary lines, caller-check results and anything you were unsure about. +## Reading code + +You may read any file in the repository with your read, search and list tools, and with +read-only shell commands (`rg`, `grep`, `find`, `cat`, `sed -n`, `git show`, `git grep`, ...). +Installed libraries (qcodes, zmq, Qt, ...) are inside the repository's virtual +environment, e.g. `.venv/lib/python3.*/site-packages/qcodes/`. Read their source files +there directly. Do not run `python -c "import inspect ..."` to print source: it needs +permission and slows everyone down. Prefer single commands over long `&&` chains. + ## When you are unsure - The plan does not say what to do → ask the orchestrator (Orca `ask`) and wait. Do not guess. diff --git a/.agents/roles/plan-checker.md b/.agents/roles/plan-checker.md index dc03708..4fb9fd6 100644 --- a/.agents/roles/plan-checker.md +++ b/.agents/roles/plan-checker.md @@ -28,6 +28,15 @@ opinion. Mark it `nit` or leave it out. If you think the *plan* is wrong (a decision looks like a mistake), do not report it as a defect in the code. Put it under Notes as "question for the user". +## Reading code + +You may read any file in the repository with your read, search and list tools, and with +read-only shell commands (`rg`, `grep`, `find`, `cat`, `sed -n`, `git show`, `git grep`, ...). +Installed libraries (qcodes, zmq, Qt, ...) are inside the repository's virtual +environment, e.g. `.venv/lib/python3.*/site-packages/qcodes/`. Read their source files +there directly. Do not run `python -c "import inspect ..."` to print source: it needs +permission and slows everyone down. Prefer single commands over long `&&` chains. + ## How you work 1. Read the plan file whole, and every file its session protocol lists (glossary, ADRs). diff --git a/.agents/roles/reviewer.md b/.agents/roles/reviewer.md index a26d5e7..49a50e0 100644 --- a/.agents/roles/reviewer.md +++ b/.agents/roles/reviewer.md @@ -22,6 +22,15 @@ Not your job: whether the tests are good enough (test reviewer), or whether the follows the plan's rules, glossary and decisions (plan checker). Mention those only if they are serious and obvious. +## Reading code + +You may read any file in the repository with your read, search and list tools, and with +read-only shell commands (`rg`, `grep`, `find`, `cat`, `sed -n`, `git show`, `git grep`, ...). +Installed libraries (qcodes, zmq, Qt, ...) are inside the repository's virtual +environment, e.g. `.venv/lib/python3.*/site-packages/qcodes/`. Read their source files +there directly. Do not run `python -c "import inspect ..."` to print source: it needs +permission and slows everyone down. Prefer single commands over long `&&` chains. + ## How you work 1. Read the plan file and the files its session protocol lists, so you know the context. diff --git a/.agents/roles/test-reviewer.md b/.agents/roles/test-reviewer.md index 08ea239..5794d2d 100644 --- a/.agents/roles/test-reviewer.md +++ b/.agents/roles/test-reviewer.md @@ -30,6 +30,15 @@ Run the task's named tests and put the summary line in your report's Notes. Not your job: general code style (general reviewer), or plan rules beyond testing (plan checker). +## Reading code + +You may read any file in the repository with your read, search and list tools, and with +read-only shell commands (`rg`, `grep`, `find`, `cat`, `sed -n`, `git show`, `git grep`, ...). +Installed libraries (qcodes, zmq, Qt, ...) are inside the repository's virtual +environment, e.g. `.venv/lib/python3.*/site-packages/qcodes/`. Read their source files +there directly. Do not run `python -c "import inspect ..."` to print source: it needs +permission and slows everyone down. Prefer single commands over long `&&` chains. + ## How you work 1. Read the plan file (especially its testing section and the task) and the files its diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 773fedf..5971e4a 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -377,7 +377,8 @@ Three layers, four new files plus the existing one: | `test/pytest/test_pm_gui.py` | pytest-qt | tabs, tints, lock toggle, arm flow, Locks panel, Types tab, live update from a second client | Conventions: proxy tests use the `param_manager` fixture; GUI tests copy the -`test_gui_navigation.py` pattern (own server on a fixed port ≥ 5600, `qtbot.waitUntil`). +`test_gui_navigation.py` pattern (own server on the `server_port` fixture, never a fixed +port, see D27; `qtbot.waitUntil`). Every error path decided above has a test asserting the exception type **and** that the message lists every offending path. @@ -398,7 +399,8 @@ Each task: what to build, files touched, acceptance, tests. One task per session `startServerGuiApplication()` calls), `test_gui_navigation.py` (`TEST_PORT = 5599`). Add to `AGENTS.md` under "Testing": "Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel." No change to `src/`. - Acceptance: `grep -rn "5555\|5599" test/pytest` finds nothing; two `uv run pytest` runs + Acceptance: `git grep -n "5555\|5599" -- test/pytest` finds nothing (tracked source only; + changed 2026-09-23 from `grep -rn`, which also matched `__pycache__` bytecode); two `uv run pytest` runs started at the same time both pass. Tests: whole suite green. - [x] **0.1 `ParameterGroup` split.** In `params.py` create `ParameterGroup(InstrumentBase)` holding parameters and nested groups with the tree helpers moved from `ParameterManager` diff --git a/opencode.json b/opencode.json index 850b7d7..b16b9f7 100644 --- a/opencode.json +++ b/opencode.json @@ -39,6 +39,29 @@ "git branch --show-current": "allow", "lsof -nP -i*": "allow", "sed -n *": "allow", + "sed -i*": "allow", + "perl -pi*": "allow", + "perl -i*": "allow", + "find *": "allow", + "git grep*": "allow", + "git ls-files*": "allow", + "git branch -a*": "allow", + "git branch --list*": "allow", + "ps*": "allow", + "lsof*": "allow", + "which *": "allow", + "file *": "allow", + "stat *": "allow", + "du *": "allow", + "tree*": "allow", + "sort*": "allow", + "uniq*": "allow", + "cut *": "allow", + "tr *": "allow", + "diff *": "allow", + "jq *": "allow", + "echo *": "allow", + "printf *": "allow", "git add *": "allow", "git commit -m*": "allow", "git push*": "deny", @@ -55,7 +78,12 @@ "git clean*": "deny", "git add -A*": "deny", "git add .*": "deny", - "git add --all*": "deny" + "git add --all*": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "find * -ok*": "deny", + "find * -fprint*": "deny" }, "webfetch": "ask", "external_directory": "ask" @@ -102,6 +130,26 @@ "git branch --show-current": "allow", "lsof -nP -i*": "allow", "sed -n *": "allow", + "find *": "allow", + "git grep*": "allow", + "git ls-files*": "allow", + "git branch -a*": "allow", + "git branch --list*": "allow", + "ps*": "allow", + "lsof*": "allow", + "which *": "allow", + "file *": "allow", + "stat *": "allow", + "du *": "allow", + "tree*": "allow", + "sort*": "allow", + "uniq*": "allow", + "cut *": "allow", + "tr *": "allow", + "diff *": "allow", + "jq *": "allow", + "echo *": "allow", + "printf *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -120,7 +168,15 @@ "git add .*": "deny", "git add --all*": "deny", "git add*": "deny", - "git commit*": "deny" + "git commit*": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "find * -ok*": "deny", + "find * -fprint*": "deny", + "sed -i*": "deny", + "perl -pi*": "deny", + "perl -i*": "deny" }, "webfetch": "ask", "external_directory": "ask" @@ -167,6 +223,26 @@ "git branch --show-current": "allow", "lsof -nP -i*": "allow", "sed -n *": "allow", + "find *": "allow", + "git grep*": "allow", + "git ls-files*": "allow", + "git branch -a*": "allow", + "git branch --list*": "allow", + "ps*": "allow", + "lsof*": "allow", + "which *": "allow", + "file *": "allow", + "stat *": "allow", + "du *": "allow", + "tree*": "allow", + "sort*": "allow", + "uniq*": "allow", + "cut *": "allow", + "tr *": "allow", + "diff *": "allow", + "jq *": "allow", + "echo *": "allow", + "printf *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -185,7 +261,15 @@ "git add .*": "deny", "git add --all*": "deny", "git add*": "deny", - "git commit*": "deny" + "git commit*": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "find * -ok*": "deny", + "find * -fprint*": "deny", + "sed -i*": "deny", + "perl -pi*": "deny", + "perl -i*": "deny" }, "webfetch": "ask", "external_directory": "ask" @@ -232,6 +316,26 @@ "git branch --show-current": "allow", "lsof -nP -i*": "allow", "sed -n *": "allow", + "find *": "allow", + "git grep*": "allow", + "git ls-files*": "allow", + "git branch -a*": "allow", + "git branch --list*": "allow", + "ps*": "allow", + "lsof*": "allow", + "which *": "allow", + "file *": "allow", + "stat *": "allow", + "du *": "allow", + "tree*": "allow", + "sort*": "allow", + "uniq*": "allow", + "cut *": "allow", + "tr *": "allow", + "diff *": "allow", + "jq *": "allow", + "echo *": "allow", + "printf *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -250,7 +354,15 @@ "git add .*": "deny", "git add --all*": "deny", "git add*": "deny", - "git commit*": "deny" + "git commit*": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "find * -ok*": "deny", + "find * -fprint*": "deny", + "sed -i*": "deny", + "perl -pi*": "deny", + "perl -i*": "deny" }, "webfetch": "ask", "external_directory": "ask" @@ -297,6 +409,26 @@ "git branch --show-current": "allow", "lsof -nP -i*": "allow", "sed -n *": "allow", + "find *": "allow", + "git grep*": "allow", + "git ls-files*": "allow", + "git branch -a*": "allow", + "git branch --list*": "allow", + "ps*": "allow", + "lsof*": "allow", + "which *": "allow", + "file *": "allow", + "stat *": "allow", + "du *": "allow", + "tree*": "allow", + "sort*": "allow", + "uniq*": "allow", + "cut *": "allow", + "tr *": "allow", + "diff *": "allow", + "jq *": "allow", + "echo *": "allow", + "printf *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -315,7 +447,15 @@ "git add .*": "deny", "git add --all*": "deny", "git add*": "deny", - "git commit*": "deny" + "git commit*": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "find * -ok*": "deny", + "find * -fprint*": "deny", + "sed -i*": "deny", + "perl -pi*": "deny", + "perl -i*": "deny" }, "webfetch": "ask", "external_directory": "ask" @@ -362,6 +502,26 @@ "git branch --show-current": "allow", "lsof -nP -i*": "allow", "sed -n *": "allow", + "find *": "allow", + "git grep*": "allow", + "git ls-files*": "allow", + "git branch -a*": "allow", + "git branch --list*": "allow", + "ps*": "allow", + "lsof*": "allow", + "which *": "allow", + "file *": "allow", + "stat *": "allow", + "du *": "allow", + "tree*": "allow", + "sort*": "allow", + "uniq*": "allow", + "cut *": "allow", + "tr *": "allow", + "diff *": "allow", + "jq *": "allow", + "echo *": "allow", + "printf *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -380,7 +540,15 @@ "git add .*": "deny", "git add --all*": "deny", "git add*": "deny", - "git commit*": "deny" + "git commit*": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "find * -ok*": "deny", + "find * -fprint*": "deny", + "sed -i*": "deny", + "perl -pi*": "deny", + "perl -i*": "deny" }, "webfetch": "ask", "external_directory": "ask" @@ -427,6 +595,26 @@ "git branch --show-current": "allow", "lsof -nP -i*": "allow", "sed -n *": "allow", + "find *": "allow", + "git grep*": "allow", + "git ls-files*": "allow", + "git branch -a*": "allow", + "git branch --list*": "allow", + "ps*": "allow", + "lsof*": "allow", + "which *": "allow", + "file *": "allow", + "stat *": "allow", + "du *": "allow", + "tree*": "allow", + "sort*": "allow", + "uniq*": "allow", + "cut *": "allow", + "tr *": "allow", + "diff *": "allow", + "jq *": "allow", + "echo *": "allow", + "printf *": "allow", "mkdir -p orchestration/*": "allow", "mkdir -p */orchestration/*": "allow", "git push*": "deny", @@ -445,7 +633,15 @@ "git add .*": "deny", "git add --all*": "deny", "git add*": "deny", - "git commit*": "deny" + "git commit*": "deny", + "find * -delete*": "deny", + "find * -exec*": "deny", + "find * -execdir*": "deny", + "find * -ok*": "deny", + "find * -fprint*": "deny", + "sed -i*": "deny", + "perl -pi*": "deny", + "perl -i*": "deny" }, "webfetch": "ask", "external_directory": "ask" From 04c4cbcf237ca89475717c9cddcc22d65c88bdb5 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 21:13:01 -0500 Subject: [PATCH 011/107] 0.3: server registers itself as a broadcast sink on Broadcaster instruments --- src/instrumentserver/server/core.py | 19 ++++ .../testing/dummy_instruments/generic.py | 30 ++++++ test/pytest/test_broadcaster.py | 99 ++++++++++++++++++- 3 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/instrumentserver/server/core.py b/src/instrumentserver/server/core.py index 52342bb..65facad 100644 --- a/src/instrumentserver/server/core.py +++ b/src/instrumentserver/server/core.py @@ -145,6 +145,12 @@ def __init__( if settings["initialize"]: self.station.load_instrument(instrumentName) + # Instruments that reached the Station from config implement the + # Broadcaster contract (ADR-0003) or not; register the server as a + # Broadcast sink on the ones that do. + for component in self.station.components.values(): + self._registerBroadcaster(component) + self.allowUserShutdown = allowUserShutdown self.listenAddresses = list(set(["127.0.0.1"] + addresses)) self.initScript = initScript @@ -185,6 +191,7 @@ def __init__( self._wakeup_w.setblocking(False) # Per-instrument locks to avoid races when multiple threads talk to the same instrument concurrently + # Prose calls these the "instrument mutex" (ADR-0003); the code keeps its current names. self._instrument_locks: dict[str, threading.RLock] = {} self._instrument_locks_lock = threading.Lock() @@ -459,6 +466,7 @@ def _createInstrument(self, spec: InstrumentCreationSpec) -> None: if new_instrument.name not in self.station.components: self.station.add_component(new_instrument) + self._registerBroadcaster(new_instrument) self.instrumentCreated.emit( bluePrintFromInstrumentModule(new_instrument.name, new_instrument), @@ -567,6 +575,17 @@ def _getGuiConfig(self, instrumentName: str) -> str: return json.dumps(self.guiConfig[instrumentName]) + def _registerBroadcaster(self, instrument: Any) -> None: + """ + Register the server as a Broadcast sink on an instrument implementing + the Broadcaster contract (ADR-0003). Instruments without the contract + are left untouched. + + :param instrument: The instrument that joined the Station. + """ + if hasattr(instrument, "add_broadcast_sink"): + instrument.add_broadcast_sink(self._broadcastParameterChange) + def _broadcastParameterChange(self, blueprint: ParameterBroadcastBluePrint) -> None: """ Broadcast any changes to parameters in the server. diff --git a/src/instrumentserver/testing/dummy_instruments/generic.py b/src/instrumentserver/testing/dummy_instruments/generic.py index aaecad6..c667d70 100644 --- a/src/instrumentserver/testing/dummy_instruments/generic.py +++ b/src/instrumentserver/testing/dummy_instruments/generic.py @@ -11,6 +11,9 @@ from qcodes import Instrument, validators from qcodes.math_utils.field_vector import FieldVector +from instrumentserver.base import Broadcaster +from instrumentserver.blueprints import ParameterBroadcastBluePrint + class StatusFlag(IntFlag): """An ``IntFlag`` mirroring drivers like the Yokogawa GS200 status byte. @@ -434,3 +437,30 @@ def get_status(self): def get_condition(self): return self._condition + + +class DummyBroadcasterInstrument(Broadcaster, Instrument): + """A dummy instrument implementing the Broadcaster contract. + + The Server registers itself as a Broadcast sink when this instrument + joins the Station. Tests call :meth:`emit_broadcast` through a proxy + to make the instrument emit a Broadcast over the Server's PUB socket. + """ + + def __init__(self, name: str, *args, **kwargs): + super().__init__(name, *args, **kwargs) + self.add_parameter("param0", set_cmd=None, initial_value=0) + + def emit_broadcast(self, value=1.0, unit="V", action="parameter-update"): + """ + Emit a Broadcast about ``param0`` and return the blueprint that was sent. + + :param value: The value carried by the Broadcast. + :param unit: The unit carried by the Broadcast. + :param action: The action string carried by the Broadcast. + """ + bp = ParameterBroadcastBluePrint( + name=f"{self.name}.param0", action=action, value=value, unit=unit + ) + self.broadcast(bp) + return bp diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py index ba9cdb2..2ae23b8 100644 --- a/test/pytest/test_broadcaster.py +++ b/test/pytest/test_broadcaster.py @@ -1,16 +1,22 @@ -"""Unit tests for the Broadcaster mixin (``instrumentserver.base``). +"""Tests for the Broadcaster contract (``instrumentserver.base``) and its +Server-side registration (``instrumentserver.server.core``). -These tests need no Server: they exercise the mixin's own behaviour — +The unit part needs no Server: it exercises the mixin's own behaviour — registering and removing sinks, fanning a Broadcast out to the sinks, tolerating an exception in one sink, and doing nothing without sinks. -The Server part of this file (the Server registering itself as a sink for -created and config-loaded instruments) is a separate task. +The Server part creates a Broadcaster instrument through a client, makes it +emit a Broadcast, and checks that a SubClient receives it; a plain dummy +instrument is checked to still work and to stay sink-free. """ import logging +import time +from contextlib import contextmanager +from instrumentserver import QtCore from instrumentserver.base import Broadcaster from instrumentserver.blueprints import ParameterBroadcastBluePrint +from instrumentserver.client.proxy import SubClient from instrumentserver.params import ParameterManager @@ -140,3 +146,88 @@ def test_parameter_manager_broadcast_reaches_sink(tmp_path, monkeypatch): pm.remove_broadcast_sink(received.append) pm.broadcast(make_bp()) assert received == [bp] + + +# --------------------------------------------------------------------------- +# Server part: the Server registers itself as a sink on Broadcaster +# instruments that join the Station, and a Broadcast emitted by such an +# instrument reaches a SubClient through the Server's PUB socket. +# --------------------------------------------------------------------------- + +BROADCASTER_INSTRUMENT_CLASS = ( + "instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument" +) + + +@contextmanager +def capture_broadcasts(instruments, sub_port): + """Run a SubClient on its own QThread and collect the Broadcasts it receives. + + Mirrors the pattern of ``test/docs_verification/helpers.py``, but takes the + Broadcast port from the ``server_port`` fixture instead of the default. + """ + received = [] + sub = SubClient( + instruments=instruments, sub_host="localhost", sub_port=sub_port + ) + sub.update.connect(received.append, QtCore.Qt.DirectConnection) + thread = QtCore.QThread() + sub.moveToThread(thread) + thread.started.connect(sub.connect) + sub.finished.connect(thread.quit) + thread.start() + # PUB/SUB slow joiner: let the SUB socket connect before Broadcasts fire. + time.sleep(0.3) + try: + yield received + finally: + sub.stop() + thread.wait(2000) + thread.deleteLater() + + +def wait_for_broadcasts(received, n=1, timeout=5.0): + """Block until at least ``n`` Broadcasts arrived, or fail with a report.""" + deadline = time.monotonic() + timeout + while len(received) < n: + if time.monotonic() > deadline: + raise AssertionError( + f"Expected {n} Broadcast(s) within {timeout}s, " + f"got {len(received)}: {received!r}" + ) + time.sleep(0.05) + + +def test_created_broadcaster_instrument_reaches_subclient(cli, start_server, server_port): + """A Broadcaster instrument created through a client has the Server as a + sink, and a method call that emits a blueprint arrives at a SubClient.""" + inst = cli.find_or_create_instrument("bcaster", BROADCASTER_INSTRUMENT_CLASS) + + # The Server registered itself as a sink on the instrument in the Station. + server_instrument = start_server.station.components["bcaster"] + assert start_server._broadcastParameterChange in server_instrument._broadcast_sinks + + with capture_broadcasts(["bcaster"], server_port + 1) as received: + inst.emit_broadcast(value=2.5, unit="V") + + wait_for_broadcasts(received) + # exactly one message: the Server registered itself once + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "bcaster.param0" + assert bp.action == "parameter-update" + assert float(bp.value) == 2.5 + assert bp.unit == "V" + + +def test_plain_dummy_instrument_still_works_and_gets_no_sink(dummy_instrument, start_server): + """A plain dummy instrument keeps working over the wire, and since it does + not implement the Broadcaster contract the Server registers no sink.""" + cli, dummy = dummy_instrument + + dummy.param0(0.5) + assert dummy.param0() == 0.5 + + server_dummy = start_server.station.components["dummy"] + assert not hasattr(server_dummy, "add_broadcast_sink") From 51672412bc7bef50c15ca6368da3c704699aba24 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 21:44:45 -0500 Subject: [PATCH 012/107] 0.3: fix from review round 1: test the config-load sink registration path --- test/pytest/test_broadcaster.py | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py index 2ae23b8..cf7c688 100644 --- a/test/pytest/test_broadcaster.py +++ b/test/pytest/test_broadcaster.py @@ -13,11 +13,15 @@ import time from contextlib import contextmanager +import qcodes as qc + from instrumentserver import QtCore from instrumentserver.base import Broadcaster from instrumentserver.blueprints import ParameterBroadcastBluePrint from instrumentserver.client.proxy import SubClient +from instrumentserver.config import loadConfig from instrumentserver.params import ParameterManager +from instrumentserver.server.core import StationServer def make_bp( @@ -231,3 +235,45 @@ def test_plain_dummy_instrument_still_works_and_gets_no_sink(dummy_instrument, s server_dummy = start_server.station.components["dummy"] assert not hasattr(server_dummy, "add_broadcast_sink") + + +def test_config_loaded_broadcaster_instrument_gets_sink( + tmp_path, server_port, qapp_session +): + """Instruments that reach the Station from a config file get the Server + registered as a Broadcast sink in ``StationServer.__init__`` (ADR-0003). + + Mirrors the production config path: an instrumentserver YAML like + ``test/docs_verification/getting_started/quickstartConfig.yml`` is split + by ``loadConfig`` into a station config and a serverConfig, and the + Server registers itself on every component the Station was loaded with. + The StationServer is constructed directly — registration happens in + ``__init__``, so no thread or socket bind is needed. + """ + config = tmp_path / "serverConfig.yml" + config.write_text( + "instruments:\n" + " cfg_bcaster:\n" + " type: instrumentserver.testing.dummy_instruments.generic." + "DummyBroadcasterInstrument\n" + " initialize: True\n" + ) + stationConfigPath, serverConfig, _, _, tempFile, _, _ = loadConfig(config) + + server = StationServer( + port=server_port, serverConfig=serverConfig, stationConfig=stationConfigPath + ) + try: + assert "cfg_bcaster" in server.station.components + component = server.station.components["cfg_bcaster"] + assert isinstance(component, Broadcaster) + assert server._broadcastParameterChange in component._broadcast_sinks + assert len(component._broadcast_sinks) == 1 + finally: + # The StationServer was never started (no thread, no bound sockets); + # close what it opened so the other tests keep a clean qcodes state. + tempFile.close() + server._wakeup_r.close() + server._wakeup_w.close() + if qc.Instrument.exist("cfg_bcaster"): + qc.Instrument.find_instrument("cfg_bcaster").close() From 88eeda0978cae2f3aa5ec76bac7c50444fb6bd85 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 22:20:40 -0500 Subject: [PATCH 013/107] 0.3: orchestration record --- PLAN_parameter_manager_redesign.md | 2 +- orchestration/0.3/decisions.md | 88 +++++++++++++++++++ orchestration/0.3/round-0/fix-list.md | 6 ++ .../0.3/round-0/plan-checker-deepseek.md | 26 ++++++ .../0.3/round-0/plan-checker-qwen.md | 26 ++++++ .../0.3/round-0/reviewer-deepseek.md | 34 +++++++ orchestration/0.3/round-0/reviewer-qwen.md | 67 ++++++++++++++ .../0.3/round-0/test-reviewer-deepseek.md | 17 ++++ .../0.3/round-0/test-reviewer-qwen.md | 34 +++++++ orchestration/0.3/round-1/fix-list.md | 3 + .../0.3/round-1/plan-checker-deepseek.md | 30 +++++++ .../0.3/round-1/plan-checker-qwen.md | 34 +++++++ .../0.3/round-1/reviewer-deepseek.md | 31 +++++++ orchestration/0.3/round-1/reviewer-qwen.md | 68 ++++++++++++++ .../0.3/round-1/test-reviewer-deepseek.md | 33 +++++++ .../0.3/round-1/test-reviewer-qwen.md | 24 +++++ 16 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 orchestration/0.3/decisions.md create mode 100644 orchestration/0.3/round-0/fix-list.md create mode 100644 orchestration/0.3/round-0/plan-checker-deepseek.md create mode 100644 orchestration/0.3/round-0/plan-checker-qwen.md create mode 100644 orchestration/0.3/round-0/reviewer-deepseek.md create mode 100644 orchestration/0.3/round-0/reviewer-qwen.md create mode 100644 orchestration/0.3/round-0/test-reviewer-deepseek.md create mode 100644 orchestration/0.3/round-0/test-reviewer-qwen.md create mode 100644 orchestration/0.3/round-1/fix-list.md create mode 100644 orchestration/0.3/round-1/plan-checker-deepseek.md create mode 100644 orchestration/0.3/round-1/plan-checker-qwen.md create mode 100644 orchestration/0.3/round-1/reviewer-deepseek.md create mode 100644 orchestration/0.3/round-1/reviewer-qwen.md create mode 100644 orchestration/0.3/round-1/test-reviewer-deepseek.md create mode 100644 orchestration/0.3/round-1/test-reviewer-qwen.md diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 5971e4a..df6843e 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -419,7 +419,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session `broadcast(bp: ParameterBroadcastBluePrint)`; sinks stored in a list; exceptions in one sink are logged and do not stop the others; no sinks → no-op. `ParameterManager` inherits it (no emissions yet). Tests: `test_broadcaster.py` unit part. -- [ ] **0.3 Server registers sinks.** In `server/core.py`: helper +- [x] **0.3 Server registers sinks.** In `server/core.py`: helper `_registerBroadcaster(instrument)` doing `hasattr(instrument, "add_broadcast_sink")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)`. Call it after `self.station.add_component(new_instrument)` in `_createInstrument` and for every diff --git a/orchestration/0.3/decisions.md b/orchestration/0.3/decisions.md new file mode 100644 index 0000000..71e437c --- /dev/null +++ b/orchestration/0.3/decisions.md @@ -0,0 +1,88 @@ +# 0.3 Server registers sinks — decisions log + +Run: run_e6f4c00ea2df. Branch: marcosfrenkel/new-param-manager. Base commit: 0fbbddf9ba0410fbbd429f5f348067726591d4d8. + +## Workers + +| agent id | terminal handle | current dispatch id | +|---|---|---| +| coder | term_fa8e9bba-840a-4c41-94dc-ffb92f711994 | ctx_fd0b239abbe7 (task_9e15d44fb49c, first implementation) | +| reviewer-deepseek | term_fd9f70ce-fbbf-41be-b89b-8dd0ef157538 | ctx_51040886c99c (task_2fe1fd041374, round 0) | +| reviewer-qwen | term_1e0050e0-7251-4873-b435-d115c441d817 | ctx_1f5916083bb7 (task_e13d8b591c4b, round 0) | +| test-reviewer-deepseek | term_1fcc956e-b7df-4afd-981e-fd72654c3ab8 | ctx_3c25a77d4dd6 (task_10986933eb7f, round 0) | +| test-reviewer-qwen | term_d25fed52-3ba6-4fa0-950a-5193ba409df8 | ctx_8e04c87e0f1b (task_8fb32080b1ba, round 0) | +| plan-checker-deepseek | term_1cf78444-2d21-466a-8fd1-60d4adc48409 | ctx_fd34c39a35ab (task_db33463445a0, round 0) | +| plan-checker-qwen | term_8f972e62-0347-47a2-a003-80701d5849fe | ctx_c741ac0934e3 (task_e3a0c52af6a1, round 0) | + +## Log + +- Checkbox 0.3 set to [~]. Base 0fbbddf. +- Carried over from run_da269441b6ac (reviewer-qwen note): once the Server registers as a sink, `ParameterManager.broadcast` becomes wire-callable, so a client could inject Broadcasts. Not part of the 0.3 task text; will be raised to the user at task end if reviewers do not raise it. +- Coder dispatched for first implementation (task_9e15d44fb49c / ctx_fd0b239abbe7). +- Coder worker_done (succeeded). Commit 04c4cbc "0.3: server registers itself as a broadcast sink on Broadcaster instruments"; files: src/instrumentserver/server/core.py, src/instrumentserver/testing/dummy_instruments/generic.py, test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit, nothing dirty outside orchestration/ and the plan. No permission prompts. Coder retained. +- Coder flagged: the config-load registration path in `__init__` shares the helper but has no dedicated test (the task's Tests line names only the created-instrument case). +- Orchestrator tests: `uv run pytest -q test/pytest/test_broadcaster.py` -> 11 passed in 7.44s; `uv run pytest -q` -> 172 passed, 4 warnings in 66.58s. +- Six reviewers dispatched for round 0 (target 0fbbddf..04c4cbc). +- Permission: plan-checker-qwen asked ls of .venv site-packages + python -c import qcodes (read-only, in worktree). Allowed once. +- Permission: reviewer-qwen asked uv run python -c import qcodes version (read-only). Allowed once. +- plan-checker-qwen worker_done (succeeded, approve, 0 findings, 2 notes). Retained. +- test-reviewer-qwen worker_done (succeeded, changes-needed: 1 must-fix config-load path untested, 2 nits). Retained. +- Permission: reviewer-qwen asked rg pyproject + uv run ruff check on changed files (read-only lint). Allowed once. +- test-reviewer-deepseek worker_done (succeeded, changes-needed: 1 should-fix config-load path untested). Retained. +- Permission: reviewer-qwen asked uv run mypy on changed files (read-only). Allowed once. +- Permission: reviewer-deepseek asked ls orchestration/0.3/round-0 + git status --short (read-only). Allowed once. +- reviewer-qwen worker_done (succeeded, approve, 0 findings, 3 notes). Retained. +- Permission: plan-checker-deepseek's worker_done send was prefixed with cd && so it prompted (allowed command). Allowed once. +- plan-checker-deepseek worker_done (succeeded, approve, 1 nit). Retained. +- reviewer-deepseek: turn ended idle after 'Let me write my report' with no report and no worker_done (liveness live). Nudged in its terminal to write the report and send worker_done. +- reviewer-deepseek worker_done after nudge (succeeded, approve, 2 nits). Retained. All six round-0 reports present. + +## Round 0 merge (six reports: 4 approve, test-reviewer-qwen and test-reviewer-deepseek changes-needed) + +- test-reviewer-qwen F1 (must-fix) + test-reviewer-deepseek F1 (should-fix): the `__init__` config-load registration loop has no test although the plan's Testing table names "created and config-loaded instruments" for test_broadcaster.py. Both models of the same role raised it. KEPT. +- test-reviewer-qwen F2 (nit): "gets no sink" asserted via hasattr(add_broadcast_sink) rather than a server-side property. Not sent: nit. +- test-reviewer-qwen F3 (nit): emit_broadcast return value not asserted. Not sent: nit. +- reviewer-deepseek N1 (nit): registration in __init__ before broadcastSocket exists is harmless. Not sent: nit (no change requested). +- reviewer-deepseek N2 (nit): test peeks at private _broadcast_sinks. Not sent: nit; the reviewer itself says no change. +- plan-checker-deepseek N1 (nit): __init__ loop covers all station components, a superset of config-loaded ones. Not sent: nit; matches ADR-0003 intent. +- plan-checker-qwen nit: `_registerBroadcaster` is camelCase while rule 8 says new methods are snake_case; the task text names the helper explicitly. Not sent: the task's explicit name wins. +- Notes for the user (not findings): (a) plan-checker-qwen and reviewer-qwen: `_runInitScript` can add instruments to the Station after the `__init__` loop, and those get no sink; the plan lists only two entry points, so out of scope for 0.3. (b) reviewer-qwen: the mixin's public methods are wire-callable on any Broadcaster proxy; inert in practice (callables do not survive JSON; a bad `broadcast(dict)` hits the logged sink-error path); a 0.2 design consequence, not a 0.3 defect. +- Fix list: 1 item -> fix round 1. +- Fix round 1 dispatched to the coder in its same terminal (task_99cd8f9b7764 / ctx_d426a391c7af). +- Permission: coder asked a python heredoc temporarily mutating the __init__ registration loop in core.py to prove the new test fails (mutation check; coder may edit). Allowed once; orchestrator will verify the fix commit leaves the loop intact. +- Coder worker_done for fix round 1 (succeeded). Commit 5167241 "0.3: fix from review round 1: test the config-load sink registration path"; only test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files, core.py __init__ loop intact (mutation check restored). Coder retained. +- Coder skipped the optional SubClient emission part of item 1: a never-started StationServer has no bound PUB socket, so emission would trip the broadcastSocket assert; the wire path is covered by the created-instrument test. +- Orchestrator tests after fix 1: named file -> 12 passed in 7.49s; full suite -> 173 passed, 4 warnings in 67.55s. +- Re-review 1 dispatched to all six reviewers in their same terminals (target 5167241): + +| agent id | round | dispatch (task) | +|---|---|---| +| plan-checker-qwen | re-review 1 | ctx_19a2de38940f (task_246d52f110fc) | +| plan-checker-deepseek | re-review 1 | ctx_7e6b9fc81a79 (task_7ba08a2058de) | +| test-reviewer-qwen | re-review 1 | ctx_26e77fcef650 (task_a2db5871dc4f) | +| test-reviewer-deepseek | re-review 1 | ctx_a33692cd45cd (task_41eaedb25a3d) | +| reviewer-qwen | re-review 1 | ctx_7e5853512036 (task_502a4ba74afe) | +| reviewer-deepseek | re-review 1 | ctx_d5d03671868b (task_62ba56ee4d95) | + +- Permission: reviewer-qwen asked uv run pytest + ruff check (test run + read-only lint). Allowed once. +- Re-review 1: test-reviewer-qwen approve (F1 fixed, F2/F3 dropped, 0 new). Retained. +- Re-review 1: plan-checker-qwen approve (0 new). Retained. +- Re-review 1: reviewer-qwen approve (0 new). Retained. +- Re-review 1: reviewer-deepseek approve (0 new). Retained. +- Re-review 1: test-reviewer-deepseek stalled on a provider 'Upstream error' with no report; plan-checker-deepseek degenerated into garbled output with no report. Both nudged in their terminals to write the report and send worker_done. +- Permission: test-reviewer-deepseek asked a garbled command containing mv and broken redirections. REJECTED; told it to use the file-write tool and send worker_done. +- Re-review 1: test-reviewer-deepseek approve after nudge (F1 fixed, 0 new). Retained. +- Re-review 1: plan-checker-deepseek approve after nudge (N1 dropped, 1 new nit). Retained. All six round-1 reports present. + +## Round 1 merge (six re-reviews, all `approve`, 0 must-fix / 0 should-fix) + +- Fix-list item 1 (config-load registration test): fixed by 5167241; confirmed by test-reviewer-qwen and test-reviewer-deepseek (both say removing the __init__ loop fails the new test). +- plan-checker-deepseek N2 (nit): new test peeks at private _broadcast_sinks, mirroring the existing test. Not sent: nit. +- test-reviewer-deepseek nit: config-load test does not also emit through a SubClient; justified (no bound PUB socket on a never-started server). Not sent: nit. +- Fix list: EMPTY. Task goes to finish. + +## Finish +- All seven workers released (Orca: state retained, processAction none, externally created terminals) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_e6f4c00ea2df: 0 rows. +- Checkbox 0.3 set to [x]. + +**Summary.** Outcome: done. Commits: `04c4cbc 0.3: server registers itself as a broadcast sink on Broadcaster instruments`, `5167241 0.3: fix from review round 1: test the config-load sink registration path`. Fix rounds used: 1. Tests (orchestrator run after fix 1): `uv run pytest -q test/pytest/test_broadcaster.py` -> 12 passed in 7.49s; `uv run pytest -q` -> 173 passed, 4 warnings in 67.55s. diff --git a/orchestration/0.3/round-0/fix-list.md b/orchestration/0.3/round-0/fix-list.md new file mode 100644 index 0000000..5c74053 --- /dev/null +++ b/orchestration/0.3/round-0/fix-list.md @@ -0,0 +1,6 @@ +# 0.3 — fix list from round 0 + +1. **Add a test for the config-load registration path.** (test-reviewer-qwen F1 must-fix; test-reviewer-deepseek F1 should-fix; the coder itself flagged the gap.) + - Where: `test/pytest/test_broadcaster.py` (server part); code under test `src/instrumentserver/server/core.py:148-152` (the `for component in self.station.components.values(): self._registerBroadcaster(component)` loop in `StationServer.__init__`). + - Why: the plan's Testing table says `test_broadcaster.py` covers "server registers sinks for created **and config-loaded** instruments". Today a removal of the `__init__` loop leaves every test green. + - Suggested shape (from the reviewers; adapt as needed): write a station-config YAML in `tmp_path` defining an instrument of type `instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument` with `initialize: True` (mirror `test/docs_verification/getting_started/quickstartConfig.yml` and the config loading pattern in `test_apps.py` / `instrumentserver.config.loadConfig`). Construct a `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly; registration happens in `__init__`, so no thread or socket bind is needed (use the `server_port` fixture so no fixed port appears). Assert the config-loaded component exists and `server._broadcastParameterChange in component._broadcast_sinks`. Tear down with `qc.Instrument.close_all()` (or whatever the existing fixtures do) so the module-scoped server's instruments are not disturbed. Optionally also emit through the component and confirm a `SubClient` receives it. diff --git a/orchestration/0.3/round-0/plan-checker-deepseek.md b/orchestration/0.3/round-0/plan-checker-deepseek.md new file mode 100644 index 0000000..343fa92 --- /dev/null +++ b/orchestration/0.3/round-0/plan-checker-deepseek.md @@ -0,0 +1,26 @@ +# 0.3 — plan-checker-deepseek — round 0 + +Verdict: approve + +## Findings + +No must-fix or should-fix findings. + +### N1 — nit +- Where: src/instrumentserver/server/core.py:146-149 (`__init__`) +- What: The config-load registration loop iterates every `self.station.components.values()` rather than only components just loaded from `serverConfig`. +- Why: The plan says "Call it … for every component after the Station is loaded from config in `__init__`." Registering over all components is a superset of the config-loaded set and stays inside the plan's intent — ADR-0003: "When an instrument joins the Station (creation over the wire, or loading from config at startup) the Server checks `hasattr(instrument, "add_broadcast_sink")` and registers its own broadcast function as a sink." No double registration occurs because `_createInstrument` only registers for instruments newly added to `station.components`, which are not present at `__init__`. +- Suggested fix: none required; leaving as-is is consistent. + +## Notes + +- Scope: commit touches only `server/core.py`, `testing/dummy_instruments/generic.py` (new `DummyBroadcasterInstrument` for the tests), and `test_broadcaster.py`. Nothing outside task 0.3. +- Plan rules checked and met: + - Helper `_registerBroadcaster(instrument)` with `hasattr(instrument, "add_broadcast_sink")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)`. Matches the task line and ADR-0003. + - Called after `self.station.add_component(new_instrument)` in `_createInstrument` (only inside the `if name not in components` branch, so a fresh instrument is registered exactly once), and for config-loaded components in `__init__`. + - One-line comment above `_instrument_locks`: `# Prose calls these the "instrument mutex" (ADR-0003); the code keeps its current names.` Matches the task line and the glossary "Instrument mutex" entry (CONTEXT line 94). `_instrument_locks` is not renamed. +- Vocabulary: all new names and comments use glossary terms — "Broadcaster", "Broadcast", "Broadcast sink", "instrument mutex". No off-glossary words introduced. `SubClient`, `DummyBroadcasterInstrument`, `emit_broadcast`, `param0` are ordinary/consistent with existing code and plan wording. +- Acceptance (task line) met point by point: dummy `Broadcaster` instrument created via `cli.find_or_create_instrument`; method `emit_broadcast` emits a blueprint received by a `SubClient`; plain dummy instrument still works and gets no sink. The config-loaded registration path is implemented but not directly tested; the task's named test list covers only the created and plain-dummy paths, so no missing named test. +- Tests run: + - `uv run pytest test/pytest/test_broadcaster.py -q` → 11 passed in 7.48s. + - `uv run pytest -q` → 172 passed, 4 warnings in 66.86s (the `PytestUnknownMarkWarning` for `integration` is pre-existing and unrelated). \ No newline at end of file diff --git a/orchestration/0.3/round-0/plan-checker-qwen.md b/orchestration/0.3/round-0/plan-checker-qwen.md new file mode 100644 index 0000000..49c8e5b --- /dev/null +++ b/orchestration/0.3/round-0/plan-checker-qwen.md @@ -0,0 +1,26 @@ +# 0.3 — plan-checker-qwen — round 0 + +Verdict: approve + +## Findings + +None. + +Checklist against task 0.3: + +- **Helper**: `_registerBroadcaster(self, instrument)` at `src/instrumentserver/server/core.py:578-587` does exactly the planned `hasattr(instrument, "add_broadcast_sink")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)`. Plan: "helper `_registerBroadcaster(instrument)` doing `hasattr(instrument, \"add_broadcast_sink\")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)`". +- **Call site 1**: `self._registerBroadcaster(new_instrument)` immediately after `self.station.add_component(new_instrument)` in `_createInstrument` (core.py:468-469), inside the same `if new_instrument.name not in self.station.components:` guard, so an existing instrument is not registered twice. Plan: "Call it after `self.station.add_component(new_instrument)` in `_createInstrument`". +- **Call site 2**: loop `for component in self.station.components.values(): self._registerBroadcaster(component)` at core.py:151-152, placed after both `Station(config_file=stationConfig)` (core.py:140, which loads the station config file) and the `load_instrument` loop (core.py:143-146). Plan: "for every component after the Station is loaded from config in `__init__`". `grep` confirms `add_component` and `load_instrument` appear nowhere else in `server/core.py`, matching the plan's "Instruments enter the Station in two places" fact. +- **Comment**: exactly one new line above `_instrument_locks` (core.py:194): `# Prose calls these the "instrument mutex" (ADR-0003); the code keeps its current names.` The preceding "Per-instrument locks…" line pre-exists at base commit 0fbbddf (verified with `git show 0fbbddf:...core.py | grep`). Names untouched, no rename. Plan: "Add a one-line comment above `_instrument_locks` noting prose calls it the \"instrument mutex\" (ADR-0003); do not rename"; ADR-0003 Consequences: "The `_instrument_locks` code in the Server is not renamed or altered". +- **Tests**: `test/pytest/test_broadcaster.py` gains the server part. `test_created_broadcaster_instrument_reaches_subclient` creates a dummy `Broadcaster` instrument (`DummyBroadcasterInstrument`) through `cli.find_or_create_instrument("bcaster", …)`, asserts the server's `_broadcastParameterChange` is a sink on the server-side instrument, calls `inst.emit_broadcast(value=2.5, unit="V")` (a method that emits a blueprint) and asserts a `SubClient` receives exactly one `ParameterBroadcastBluePrint` with `name="bcaster.param0"`, `action="parameter-update"`, `value=2.5`, `unit="V"`. `test_plain_dummy_instrument_still_works_and_gets_no_sink` exercises `dummy.param0` set/get over the wire and asserts `not hasattr(server_dummy, "add_broadcast_sink")`. All three bullets of the plan's test line are covered: "a dummy `Broadcaster` instrument created through `cli.find_or_create_instrument`; calling a method on it that emits a blueprint is received by a `SubClient`; a plain dummy instrument still works and gets no sink". +- **Scope**: commit touches only `src/instrumentserver/server/core.py`, `src/instrumentserver/testing/dummy_instruments/generic.py` (new `DummyBroadcasterInstrument`, required for the named test and shipped in `instrumentserver.testing` per the "Dummy Instrument" glossary entry) and `test/pytest/test_broadcaster.py`. No 0.4/0.5 work (`_newOrDeleteParameterDetection` kwargs, `apps.py` sub_port, action constants), no `params.py`/`blueprints.py` changes, no renames. +- **Vocabulary**: "Broadcaster contract", "Broadcast sink", "joins the Station", "emits a Broadcast", "instrument mutex", "PUB socket", "SubClient", "dummy instrument" — all CONTEXT.md / plan / ADR-0003 terms in their glossary meanings; no "notification", "event emitter", "instrument lock" etc. +- **Protected behaviour**: no existing method signatures or behaviour changed; `_instrument_locks` and the lock code untouched; the `instrumentCreated` emission in `_createInstrument` unchanged. +- **Plan rules**: commit message starts with the task number ("0.3: …"); single atomic commit; tests per layer (server part runs against the module-scoped `start_server` fixture on the `server_port` fixture, no fixed port, per D27). + +## Notes + +- Tests run: `uv run pytest test/pytest/test_broadcaster.py` → `11 passed in 7.54s`; full `uv run pytest` → `172 passed, 4 warnings in 66.70s` (warnings pre-existing: `PytestUnknownMarkWarning` in `test_apps.py`). +- Question for the user (plan, not code): task 0.3 registers sinks at the two entry points the plan names, but `StationServer._runInitScript` (core.py:242, called from `startServer` after `__init__`) passes the live `Station` to user exec-able code, which could add instruments after the `__init__` registration loop. The plan states "Instruments enter the Station in two places" as fact; if init scripts are assumed never to add instruments, this is moot — otherwise a future task could cover it. The commit follows the plan as written. +- Nit (not sent to the coder): the plan's rule 8 says "New methods are `snake_case`" while task 0.3 itself names the helper `_registerBroadcaster` (camelCase, matching the existing server style `_callObject`, `_createInstrument`). The commit follows the task's explicit name, which is the more specific instruction; no action needed. +- The new `capture_broadcasts` helper's docstring reference to `test/docs_verification/helpers.py` is accurate (that file exists and uses the same SubClient-on-QThread pattern). diff --git a/orchestration/0.3/round-0/reviewer-deepseek.md b/orchestration/0.3/round-0/reviewer-deepseek.md new file mode 100644 index 0000000..3e8939e --- /dev/null +++ b/orchestration/0.3/round-0/reviewer-deepseek.md @@ -0,0 +1,34 @@ +# 0.3 — reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +No must-fix or should-fix findings. + +### N1 — nit +- Where: src/instrumentserver/server/core.py:148-152 +- What: The config-load registration loop and its comment sit inside `__init__`, before `broadcastSocket` exists. +- Why: Harmless — `_registerBroadcaster` only appends `self._broadcastParameterChange` to the instrument's sink list; it never invokes the sink, so the `assert self.broadcastSocket is not None` in `_broadcastParameterChange` can only run once a request executes, after `startServer` has bound the socket. +- Suggested fix: None needed; recorded for completeness. + +### N2 — nit +- Where: test/pytest/test_broadcaster.py:159 and src/instrumentserver/testing/dummy_instruments/generic.py:462 +- What: The server test peeks at the private `_broadcast_sinks` list, and `DummyBroadcasterInstrument` exposes a small method solely for the test. +- Why: Checking the private sink list is a direct, honest assertion that the required registration happened (and that a plain dummy gets none), which is exactly what the plan's test spec asks to prove; the per-test dummy method is the minimal way to make an instrument emit through a proxy. Neither warrants a change. +- Suggested fix: None. + +## Notes + +- Reviewed the full diff for `04c4cbc` (`_registerBroadcaster` helper, its two call sites, the `_instrument_locks` comment, `DummyBroadcasterInstrument`, and the server part of `test_broadcaster.py`), plus the surrounding server code, the `Broadcaster` mixin in `base.py`, the three ADRs, `CONTEXT.md`, and `conftest.py`. +- Conformance to the task: + - `_registerBroadcaster(instrument)` does `hasattr(instrument, "add_broadcast_sink")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)` (core.py:586-587). + - Called after `self.station.add_component(new_instrument)` in `_createInstrument` (core.py:469). + - Called for every component after the Station is loaded from config in `__init__` (core.py:151-152; the loop runs after the `self.station.load_instrument(...)` loop and only config-loaded components are in `self.station.components` at that point). + - One-line comment directly above `_instrument_locks` noting prose calls it the "instrument mutex" (ADR-0003) with the code names kept and nothing renamed (core.py:194). + - Tests cover all three plan-specified server cases: created `Broadcaster` instrument reaches a `SubClient` (asserting exactly one message, i.e. no double registration), and a plain dummy instrument still works and stays sink-free. +- A created `Broadcaster` instrument registers exactly once: `_createInstrument` calls `_registerBroadcaster` only when the name is not already in `self.station.components`, and the config path only touches config-loaded instruments, so the two paths cannot double-register the same instrument. +- `broadcast` runs on the worker thread holding the instrument mutex, the same thread the Server's own broadcasts already use, matching ADR-0003. +- Tests run: + - `uv run pytest test/pytest/test_broadcaster.py` → 11 passed in 7.46s. + - `uv run pytest test/pytest/` → 172 passed in 66.35s (whole suite green). \ No newline at end of file diff --git a/orchestration/0.3/round-0/reviewer-qwen.md b/orchestration/0.3/round-0/reviewer-qwen.md new file mode 100644 index 0000000..324886a --- /dev/null +++ b/orchestration/0.3/round-0/reviewer-qwen.md @@ -0,0 +1,67 @@ +# 0.3 — reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +None. + +I checked the commit (`04c4cbc`) line by line against the task and the surrounding code: + +- `_registerBroadcaster` (`src/instrumentserver/server/core.py:578-587`) is exactly the + plan's helper: `hasattr(instrument, "add_broadcast_sink")` → + `instrument.add_broadcast_sink(self._broadcastParameterChange)`. It is called at the two + plan-named sites: after `self.station.add_component(new_instrument)` in + `_createInstrument` (`core.py:468-469`), and for every component after the Station is + loaded from config in `__init__` (`core.py:148-152`). I verified in qcodes 0.58 + (`.venv/.../qcodes/station.py`) that `Station(config_file=...)` only parses the YAML and + that instruments actually enter the Station via `load_instrument` (`core.py:146`), which + runs before the loop — so the loop covers precisely the config-loaded instruments. + `git grep` confirms those two are the only places in `src/` where components enter the + Station (the init script is a third, out-of-scope path, see Notes). +- Registration happens in `__init__` before `broadcastSocket` exists, which is safe: + the bound method only dereferences the socket when it is later invoked on a worker + thread, the same place the Server's own broadcasts already run (ADR-0003 consequence). + Double registration is impossible: a config-loaded instrument later reached by + `find_or_create_instrument` is already in `station.components` and is skipped by the + same `if` guard as `add_component`. +- The one-line comment above `_instrument_locks` (`core.py:194`) notes the "instrument + mutex" prose name (ADR-0003); the name is untouched, as the task requires. +- `DummyBroadcasterInstrument(Broadcaster, Instrument)` + (`src/instrumentserver/testing/dummy_instruments/generic.py:442-465`) has a correct MRO + (`Broadcaster.__init__` → `Instrument.__init__`), and `emit_broadcast` is picked up by + `bluePrintFromInstrumentModule` (public, not on the base class), so it is callable + through the client proxy — which the new test does. +- The two new tests cover both plan scenarios: a Broadcaster created through + `cli.find_or_create_instrument` emitting a blueprint that a `SubClient` receives + (and exactly once, pinning the single-registration semantics), and a plain dummy + instrument still working with no sink. The SubClient thread pattern is the established + one from `test/docs_verification/helpers.py` and is free of the usual Qt + cross-threading traps (`DirectConnection` append runs on the SubClient's thread, so + blocking the main thread in `wait_for_broadcasts` cannot deadlock it). +- Naming and structure follow the file's conventions (camelCase private server methods, + `:param:` docstrings, `ADR-0003` reference style used elsewhere in the ADRs). + +## Notes + +- Tests run: `uv run pytest test/pytest/test_broadcaster.py -v` → `11 passed in 7.46s`; + `uv run pytest` (whole suite) → `172 passed, 4 warnings in 66.33s` (the 4 warnings are + the pre-existing unregistered `pytest.mark.integration` in `test_apps.py`). +- `uv run ruff check` on the three changed files: all passed; `uv run mypy` on the two + `src/` files: no issues. +- The mixin's public methods (`add_broadcast_sink`, `remove_broadcast_sink`, `broadcast`) + show up in instrument blueprints as remotely callable methods (e.g. they would appear + on the Parameter Manager's proxy once it emits). They are inert over the wire — + callables do not survive JSON, and a malformed remote `broadcast(dict)` only reaches + the mixin's logged-and-swallowed sink error path — but it is a design consequence of + task 0.2's public contract, not of this commit. Flagging for the plan checker / a + future task, not a finding here. +- The init script (`_runInitScript`, executed in `startServer`) can add components to + the Station after the `__init__` registration loop; instruments added that way would + not get a sink. The task explicitly scopes registration to the two named sites, and + the plan's architecture section lists the same two entry points, so this is out of + scope — worth keeping in mind for the Phase 6 broadcasts docs page. +- `capture_broadcasts` / `wait_for_broadcasts` in the test duplicate ~25 lines of + `test/docs_verification/helpers.py`; that helper is only importable by the standalone + script convention (`sys.path` insert), so the duplication is justified, and the test + docstring says so. diff --git a/orchestration/0.3/round-0/test-reviewer-deepseek.md b/orchestration/0.3/round-0/test-reviewer-deepseek.md new file mode 100644 index 0000000..e997b8b --- /dev/null +++ b/orchestration/0.3/round-0/test-reviewer-deepseek.md @@ -0,0 +1,17 @@ +# 0.3 — test-reviewer-deepseek — round 0 + +Verdict: changes-needed + +## Findings + +### F1 — should-fix +- Where: `test/pytest/test_broadcaster.py` (server part) — no test for the config-load path in `src/instrumentserver/server/core.py:151-152` +- What: The `__init__` config-load registration branch (`for component in self.station.components.values(): self._registerBroadcaster(component)`, core.py:151-152) has no test; the server part covers only the `_createInstrument` branch (line 469). +- Why: The task text and the plan's Testing table both state registration applies to "created and config-loaded instruments", and the commit implements both paths (core.py:151-152 and 469). Currently a `git rm` of the `__init__` loop would leave every test green. This visits a code path the plan says the file must cover. +- Suggested fix: Add a test that starts the server with a `serverConfig` whose `initialize: True` names a Broadcaster instrument (mirroring the config fixtures in `test_apps.py`/`test_config.py`), then assert the sink was registered — e.g. `server.station.components[]._broadcast_sinks` contains `server._broadcastParameterChange` — and ideally repeat the `SubClient`-receives-emission check, mirroring `test_created_broadcaster_instrument_reaches_subclient`. Setup: server constructed with the config; action: `_registerBroadcaster` runs over config-loaded components in `__init__`; expected: the config-tracked instrument has the server as a sink. + +## Notes +- Severity: 0 must-fix, 1 should-fix, 0 nit. +- The three named tests are present and meaningful: (1) `test_created_broadcaster_instrument_reaches_subclient` — created-through-`cli` Broadcaster instrument is a registered sink and a method call that emits a blueprint arrives at a `SubClient` with the right name/action/value/unit and exactly one message; (2) `test_plain_dummy_instrument_still_works_and_gets_no_sink` — a plain dummy works over the wire and has no sink; (3) the unit part covers the mixin unchanged. The "exactly one message" assertion pins that the server registered itself once and that `_callObject` does not double-broadcast a plain method. The instrument-mutex comment (core.py:194) is present verbatim. Vocabulary matches the glossary (Broadcaster, broadcast, sink, instrument mutex). +- Tests run: `uv run pytest test/pytest/test_broadcaster.py` — 11 passed in 7.47s (9 unit + 2 server). +- This review covers only commit 04c4cbc; the 0.2 unit part (Broadcaster mixin) was out of scope. \ No newline at end of file diff --git a/orchestration/0.3/round-0/test-reviewer-qwen.md b/orchestration/0.3/round-0/test-reviewer-qwen.md new file mode 100644 index 0000000..7d9f97c --- /dev/null +++ b/orchestration/0.3/round-0/test-reviewer-qwen.md @@ -0,0 +1,34 @@ +# 0.3 — test-reviewer-qwen — round 0 + +Verdict: changes-needed + +## Findings + +### F1 — must-fix +- Where: `test/pytest/test_broadcaster.py` (server part) + `src/instrumentserver/server/core.py:148-152` +- What: The config-load registration path has no test. The commit adds a loop in `StationServer.__init__` that calls `_registerBroadcaster` for every component loaded from the station config, but no test exercises that path; `test_broadcaster.py` only covers the created-over-the-wire path. +- Why: The plan's Testing table is explicit that this file covers "server registers sinks for created **and config-loaded** instruments", and D23 points at that table as the test plan. The `__init__` loop is brand-new code from this commit and is the only place the config-load requirement ("for every component after the Station is loaded from config in `__init__`") is implemented. No other task names this coverage. If that loop regressed, a `Broadcaster` instrument loaded from config at startup would silently never emit, and no test would fail. +- Suggested fix: Add a test that loads a `DummyBroadcasterInstrument` from config and asserts the server registered itself as its sink. Setup: write a station-config YAML defining `bcaster_cfg` → `instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument` with `initialize: True` (mirror `test/docs_verification/getting_started/quickstartConfig.yml`), derive `serverConfig`/`stationConfig` via `instrumentserver.config.loadConfig` (the pattern `test_apps.py` already uses). Action: construct a `StationServer(port=server_port, serverConfig=..., stationConfig=...)` — registration happens in `__init__`, so no thread or port bind is needed; use the `server_port` fixture and `qapp_session`. Expected: `server.station.components["bcaster_cfg"]` exists and `server._broadcastParameterChange in server.station.components["bcaster_cfg"]._broadcast_sinks` (mirroring the white-box assert in `test_created_broadcaster_instrument_reaches_subclient`); tear down with `qc.Instrument.close_all()`. Optionally also emit via the component and confirm a `SubClient` receives it, to cover the full path. + +### F2 — nit +- Where: `test/pytest/test_broadcaster.py::test_plain_dummy_instrument_still_works_and_gets_no_sink` +- What: The "gets no sink" assertion checks `not hasattr(server_dummy, "add_broadcast_sink")`, which is a property of the dummy's class hierarchy rather than of the Server's behaviour. +- Why: Because registration is gated on `hasattr(instrument, "add_broadcast_sink")`, this is a logically valid proxy and the "still works" set/get part is a genuine behaviour check, so it is acceptable as-is — but a direct `not hasattr(server_dummy, "_broadcast_sinks")` (or asserting `_registerBroadcaster` added nothing) would state the intent more precisely. Preference only. + +### F3 — nit +- Where: `test/pytest/test_broadcaster.py::test_created_broadcaster_instrument_reaches_subclient` +- What: `inst.emit_broadcast(value=2.5, unit="V")` is called but its return value (the `ParameterBroadcastBluePrint` the method returns) is never asserted. +- Why: The plan requires client-facing method return values to be JSON-serialisable blueprints that round-trip through the proxy; asserting the returned blueprint equals what was sent would pin that. The broadcast-on-the-wire is already asserted, so this is a missed opportunity, not a broken test. Preference only. + +## Notes + +- All three tests named in task 0.3's "Tests:" line are present and meaningful: + - `test_created_broadcaster_instrument_reaches_subclient` — creates a `DummyBroadcasterInstrument` via `cli.find_or_create_instrument`, white-box asserts the server is in its `_broadcast_sinks`, then a proxy method call that emits a blueprint is received by a `SubClient` (asserts topic, action, value, unit, and `len(received) == 1` to catch double-registration). Right layer (server/proxy). Fails if the sink is not registered. + - `test_plain_dummy_instrument_still_works_and_gets_no_sink` — plain dummy still set/gets over the wire and has no sink. +- The two new tests are at the correct layer and use the plan's vocabulary (Broadcaster, SubClient, Broadcast). The `capture_broadcasts`/`wait_for_broadcasts` helpers faithfully mirror the established `test/docs_verification/helpers.py` pattern (SubClient on its own QThread, `DirectConnection`, PUB/SUB slow-joiner sleep, GIL-safe list append), so the threading is sound and not flaky-prone by design. +- `DummyBroadcasterInstrument` is added to `src/instrumentserver/testing/dummy_instruments/generic.py`; that module is only imported on demand (both `__init__.py`s are empty), so there is no import-time or circular-import risk, and the existing API is untouched. `Broadcaster.__init__` correctly chains into `Instrument.__init__` and `param0` is added after `super().__init__()`. +- The `_registerBroadcaster` helper and the `_createInstrument` call site match the task exactly; the one-line "instrument mutex" comment is present above `_instrument_locks` and nothing was renamed. +- No existing test was weakened, deleted, or skipped; the nine unit tests from task 0.2 are unchanged. +- Tests run: + - `uv run pytest test/pytest/test_broadcaster.py -v` → `11 passed in 7.45s` + - `uv run pytest` (whole suite) → `172 passed, 4 warnings in 66.92s` (the 4 warnings are pre-existing `pytest.mark.integration` unknown-mark warnings in `test_apps.py`, unrelated to this commit). diff --git a/orchestration/0.3/round-1/fix-list.md b/orchestration/0.3/round-1/fix-list.md new file mode 100644 index 0000000..776f761 --- /dev/null +++ b/orchestration/0.3/round-1/fix-list.md @@ -0,0 +1,3 @@ +# 0.3 — fix list from round 1 + +Empty. All six re-reviews approve with no must-fix or should-fix findings. diff --git a/orchestration/0.3/round-1/plan-checker-deepseek.md b/orchestration/0.3/round-1/plan-checker-deepseek.md new file mode 100644 index 0000000..6772557 --- /dev/null +++ b/orchestration/0.3/round-1/plan-checker-deepseek.md @@ -0,0 +1,30 @@ +# 0.3 — plan-checker-deepseek — round 1 + +Verdict: approve + +## Previous findings + +### N1 — nit (previous round) +- Where: src/instrumentserver/server/core.py:146-149 (`__init__`) +- What: the `__init__` config-load registration loop iterates every `self.station.components.values()` rather than only components just loaded from `serverConfig`. +- Why: the plan says "Call it … for every component after the Station is loaded from config in `__init__`." Registering over all components is a superset of the config-loaded set but stays inside the plan's intent. +- Status: **dropped by orchestrator** — decisions.md "Round 0 merge": "plan-checker-deepseek N1 (nit): __init__ loop covers all station components, a superset of config-loaded ones. Not sent: nit; matches ADR-0003 intent." The `__init__` loop is unchanged (still at core.py:152) and remains consistent with the plan and ADR-0003, so dropping is fine. + +## Findings (round 1) + +No must-fix or should-fix findings, and no new findings in the plan-checker lane. + +### N2 — nit +- Where: test/pytest/test_broadcaster.py (`test_config_loaded_broadcaster_instrument_gets_sink`) +- What: the new test peeks at the private `component._broadcast_sinks`. +- Why: not a plan rule; it mirrors the existing created-instrument test (`test_created_broadcaster_instrument_reaches_subclient`), which does the same private peek. A server-side public accessor is out of this task's scope. +- Suggested fix: none; consistent with the existing tests. + +## Notes + +- Fix commit 5167241 is test-only: it adds `test_config_loaded_broadcaster_instrument_gets_sink` to `test/pytest/test_broadcaster.py` and changes no `src/` code. The `__init__` register loop (core.py:152) and the `_createInstrument` registration (core.py:469) are intact, matching the plan's two entry points and ADR-0003. +- The new test genuinely guards the config-load path: it writes a config YAML with `instruments:` -> `cfg_bcaster` (`type: instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument`, `initialize: True`), runs it through `loadConfig`, constructs `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly (registration happens in `__init__`; no thread or socket bind needed), and asserts the component exists, is a `Broadcaster`, and that `server._broadcastParameterChange` is in its `_broadcast_sinks`. Removing the `__init__` loop would fail the test, so it is not vacuous. It uses the `server_port` fixture (no fixed port, per D27). Teardown closes the temp file, the wake-up socket pair, and the config-loaded `cfg_bcaster` instrument, leaving the module-scoped `start_server` fixtures unharmed. +- The fix-test vocabulary uses glossary terms: "Broadcaster", "Broadcast sink", `serverConfig`/`stationConfig` — consistent. The optional SubClient emission part was reasonably skipped (a never-started StationServer has no bound PUB socket; the wire path is already covered by the created-instrument test). +- Tests run: + - `uv run pytest test/pytest/test_broadcaster.py -q` -> 12 passed in 7.44s. + - Orchestrator's full suite after fix 1: 173 passed, 4 warnings (per decisions.md). \ No newline at end of file diff --git a/orchestration/0.3/round-1/plan-checker-qwen.md b/orchestration/0.3/round-1/plan-checker-qwen.md new file mode 100644 index 0000000..4660329 --- /dev/null +++ b/orchestration/0.3/round-1/plan-checker-qwen.md @@ -0,0 +1,34 @@ +# 0.3 — plan-checker-qwen — round 1 + +Verdict: approve + +## Previous findings + +My round-0 report (orchestration/0.3/round-0/plan-checker-qwen.md) had **no findings** (verdict: approve, 0 must-fix / 0 should-fix / 0 nit), so nothing from my report was fixed, not fixed, or dropped by the orchestrator. The single fix-list item (`orchestration/0.3/round-0/fix-list.md`, item 1: "Add a test for the config-load registration path") came from the test reviewers, not from me. For completeness, my two round-0 non-findings were both dropped by the orchestrator on purpose, per `orchestration/0.3/decisions.md` "Round 0 merge": the nit (`_registerBroadcaster` camelCase vs rule 8 "New methods are `snake_case`" — "Not sent: the task's explicit name wins") and the user-facing Notes (`_runInitScript` could add instruments after the `__init__` loop; plan lists only two entry points) — the latter remains a plan question, not a 0.3 requirement, so it stays out of scope. + +Fix commit 5167241 implements exactly fix-list item 1 and only that: `git diff 04c4cbc 5167241 --stat` shows 46 insertions, 0 deletions, in `test/pytest/test_broadcaster.py` alone. No `src/` change. + +## Did the fix break or weaken anything in my focus area? + +No. + +- **Implementation intact**: the `_registerBroadcaster` helper, both call sites, and the one-line "instrument mutex" comment are unchanged — verified by `git show 5167241:src/instrumentserver/server/core.py` (the `for component in self.station.components.values(): self._registerBroadcaster(component)` loop at core.py:151-152 is byte-identical to round 0). +- **Round-0 tests untouched**: the fix is purely additive; all 11 round-0 tests still pass unchanged. +- **New test stays in task 0.3 scope**: it exercises the task's second call site — plan task 0.3: "for every component after the Station is loaded from config in `__init__`" — and the plan's Testing table line: "server registers sinks for created **and config-loaded** instruments". It uses the real production path: `instrumentserver.config.loadConfig` splits a YAML (same shape as `test/docs_verification/getting_started/quickstartConfig.yml`, which the docstring names) into a station config plus `serverConfig`, and a directly constructed `StationServer` loads `cfg_bcaster` via the `load_instrument` loop in `__init__` (qcodes' `Station(config_file=…)` only reads the config; `load_instrument` is what instantiates and `add_component`s, qcodes/station.py:717). `loadConfig`'s 7-value return order was checked against `src/instrumentserver/config.py:192-200` — the unpack `stationConfigPath, serverConfig, _, _, tempFile, _, _` is correct. +- **Commit rule**: message starts with the task number ("0.3: fix from review round 1: …"); separate fix-round commit, per session protocol step 6 ("each round of review fixes is its own commit"). +- **No fixed port**: uses the `server_port` fixture; `git grep -n "5555\|5599" -- test/pytest` finds nothing (task 0.0 acceptance holds). + +## New findings + +None. + +- **Mutation-sensitive**: the test asserts `server._broadcastParameterChange in component._broadcast_sinks` **and** `len(component._broadcast_sinks) == 1`; removing the `__init__` loop leaves `_broadcast_sinks` empty, so the test fails — it closes exactly the gap the fix list named ("Today a removal of the `__init__` loop leaves every test green"). +- **No interference with other tests**: the new test does not use the `start_server`/`cli` fixtures and creates no instruments on the running server. Constructing the throwaway `Station` reassigns `Station.default`, but in this qcodes version `Station.default` is referenced only by `dataset/measurements.py`, never during instrument creation; the running server holds its own `self.station` reference. Cleanup in `finally` (close `tempFile`, close both ends of the socketpair, close `cfg_bcaster` guarded by `qc.Instrument.exist`) matches what the test opened. The full suite passing confirms no cross-module disturbance. +- **Vocabulary**: "config-loaded broadcaster instrument", "get(s) a sink", "Broadcast sink", "reaches the Station from a config file", "component" (mirrors the plan's "for every component"), "ADR-0003" — all glossary/plan/ADR terms in their glossary meanings. +- **Skipped optional part**: the coder did not add the SubClient-emission part of the fix-list suggestion ("Optionally also emit through the component…"), with a sound reason recorded in `orchestration/0.3/decisions.md` (a never-started `StationServer` has no bound PUB socket; the wire path is already covered by `test_created_broadcaster_instrument_reaches_subclient`). The plan's task line for 0.3 names only the created-instrument and plain-dummy cases, so the mandated coverage (sink registered on a config-loaded instrument) is complete without it. + +## Notes + +- Tests run: `uv run pytest test/pytest/test_broadcaster.py` → `12 passed in 7.45s`; full `uv run pytest` → `173 passed, 4 warnings in 66.66s` (warnings pre-existing: `PytestUnknownMarkWarning` in `test_apps.py`). +- Observation (not a finding, out of scope per plan rule 6 "Do not widen scope"): `loadConfig` returns a `NamedTemporaryFile(delete=False)` and the test (like the rest of the codebase) only closes it, so a temp station-config file is left in the system temp dir per run. That is pre-existing `loadConfig` behaviour, not introduced by this commit. +- Carried-over user question from round 0 (unchanged): if a user init script adds instruments to the Station via `_runInitScript` (core.py:242, runs after `__init__`), those instruments get no sink; the plan names only two entry points, so this remains a plan-level question, not a 0.3 defect. diff --git a/orchestration/0.3/round-1/reviewer-deepseek.md b/orchestration/0.3/round-1/reviewer-deepseek.md new file mode 100644 index 0000000..c01e18c --- /dev/null +++ b/orchestration/0.3/round-1/reviewer-deepseek.md @@ -0,0 +1,31 @@ +# 0.3 — reviewer-deepseek — round 1 + +Verdict: approve + +## Previous findings + +Both of my round-0 findings were **dropped by the orchestrator** (decisions.md, "Round 0 merge", lines 45-46: "Not sent: nit"). No fix was requested from the coder on my account; the only fix-list item was the config-load path gap raised by the test-reviewers. + +- N1 — nit (src/instrumentserver/server/core.py:148-152, registration in `__init__` before `broadcastSocket` exists): dropped by orchestrator. Harmless, as my round-0 note said. +- N2 — nit (test peeks at private `_broadcast_sinks`; per-test dummy method): dropped by orchestrator. No change warranted. + +## Did the fix commit break or weaken anything? + +No. `5167241` is test-only (`test/pytest/test_broadcaster.py`, +46 lines); there is no `src/` change, so no runtime behaviour changed and nothing in my focus area (production correctness/clarity) was touched. Suite is fully green: + +- `uv run pytest test/pytest/test_broadcaster.py` → 12 passed in 7.45s (was 11). +- `uv run pytest test/pytest/` → 173 passed in 66.55s. + +The new `test_config_loaded_broadcaster_instrument_gets_sink` test is mutation-sensitive for the code it pins: if the `__init__` loop (core.py:151-152) were removed, `component._broadcast_sinks` would be empty and `assert server._broadcastParameterChange in component._broadcast_sinks` would fail. It also asserts exactly one registration (`len(component._broadcast_sinks) == 1`), matching the created-instrument test's exactly-one message, so a careless double-registration in the config path would be caught here. It uses the `server_port` fixture (no fixed port, per D27), constructs a never-started `StationServer` directly — correct, since the loop runs in `__init__` and no socket bind/thread is needed — and its `finally` block closes the temp file, the socketpair, and the `cfg_bcaster` instrument, so it leaves no qcodes state behind that could disturb the other tests in the same file. + +The `loadConfig` unpack matches the function's actual 7-tuple return (config.py:192-200), and the YAML written mirrors the production config shape the quickstart config uses. + +## New findings + +None in my scope (correctness, clarity, consistency). The change is purely additive test coverage of the config-load registration path, which closes the one real coverage gap the round-0 test-reviewers raised (and that the plan's Testing-table row for `test_broadcaster.py` names: "created and config-loaded instruments"). + +## Notes + +- Verified the fix commit with `git show 5167241 --stat` and `git show 5167241`: only `test/pytest/test_broadcaster.py` touched; the `__init__` registration loop in `core.py` is intact. +- Reviewed the new test in the context of `loadConfig` (instrumentserver/config.py:21-200), `StationServer.__init__` (core.py:106-199), and the existing server tests in the file. +- Whole suite re-run by me: 173 passed in 66.55s. \ No newline at end of file diff --git a/orchestration/0.3/round-1/reviewer-qwen.md b/orchestration/0.3/round-1/reviewer-qwen.md new file mode 100644 index 0000000..31ecfd7 --- /dev/null +++ b/orchestration/0.3/round-1/reviewer-qwen.md @@ -0,0 +1,68 @@ +# 0.3 — reviewer-qwen — round 1 + +Verdict: approve + +## Previous findings + +My round-0 report had no findings (verdict: approve; three informational Notes only), so +there is nothing to be fixed or dropped. The single fix-list item (test for the +config-load registration path) came from test-reviewer-qwen/deepseek and the coder's own +flag, not from my report. For the record, my two round-0 notes (the init-script +registration gap, out of scope per the task; the mixin's wire-callable public methods, a +0.2 consequence) are carried in `decisions.md` as "Notes for the user" and remain +informational. + +## Fix commit review (5167241) + +The commit adds one test, `test_config_loaded_broadcaster_instrument_gets_sink`, to +`test/pytest/test_broadcaster.py` (+46 lines; `git diff 04c4cbc..5167241 --stat` confirms +no other file changed, so my round-0 code-level conclusions stand). + +- **It pins the right code.** The test writes a production-style server config + (`instruments: cfg_bcaster: {type: ...DummyBroadcasterInstrument, initialize: True}`), + splits it with `loadConfig` (unpacking the 7-tuple return in the correct order, + verified against `src/instrumentserver/config.py:192-199`), and constructs + `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly. + In qcodes 0.58 the config-loaded instrument reaches the Station only through + `load_instrument` inside `__init__` (qcodes `station.py:717` `add_component`), so the + registration loop at `src/instrumentserver/server/core.py:151-152` is the *only* code + that can put `server._broadcastParameterChange` into `component._broadcast_sinks`. + The `in ...` plus `len(...) == 1` asserts therefore fail if the loop is removed — the + test genuinely covers the previously untested path. +- **No interference with the running suite state.** The `StationServer` is constructed + but never started: no port is bound (no collision with the module-scoped server on the + same `server_port`), no thread is spawned. Its side effects are all cleaned up: the + `loadConfig` temp-file handle and both wakeup socketpair ends are closed in `finally`, + and `cfg_bcaster` is closed via `qc.Instrument.find_instrument(...).close()` guarded by + `exist()`. The one residual — the new `Station` becomes `Station.default` + (qcodes `station.py:164`, strong reference) and outlives the test — is harmless: the + only qcodes consumer of `Station.default` is `Measurements.__init__` + (`measurements.py:661`), which neither this codebase nor the test suite uses, and the + live module server holds an explicit `self.station` reference. `update_monitor()` in + `load_instrument` does not open a Qt Monitor (default `station.use_monitor` is + falsy). +- **The skipped optional part is reasonable.** The fix list suggested optionally emitting + through the component to a `SubClient`; the coder skipped it because a never-started + `StationServer` has no bound PUB socket and `_broadcastParameterChange` asserts on + `broadcastSocket`. The wire path is already covered by the created-instrument test. + Noted in `decisions.md`; no objection. +- **Nothing weakened.** The two existing server tests are untouched; the new test adds no + fixture dependencies beyond `tmp_path` / `server_port` / `qapp_session` and would pass + standalone even without the module server. + +## New findings + +None. + +## Notes + +- Tests run: `uv run pytest test/pytest/test_broadcaster.py -v` → `12 passed in 7.45s`; + `uv run pytest` (whole suite) → `173 passed, 4 warnings in 66.43s` (same pre-existing + `pytest.mark.integration` warnings as round 0). `uv run ruff check + test/pytest/test_broadcaster.py` → all checks passed. +- White-box style: the new test reaches into `StationServer` privates + (`_wakeup_r`/`_wakeup_w`) and the mixin's `_broadcast_sinks`, matching the existing + pattern in this file and in `conftest.py` (`server.SAFEWORD`); consistent, no finding. +- `loadConfig` writes a `NamedTemporaryFile(delete=False)` that persists on disk after + the test (only the handle is closed); this is pre-existing production behaviour in + `config.py`, identical to how `apps.py` uses it, and not changed by this commit. diff --git a/orchestration/0.3/round-1/test-reviewer-deepseek.md b/orchestration/0.3/round-1/test-reviewer-deepseek.md new file mode 100644 index 0000000..2a67d11 --- /dev/null +++ b/orchestration/0.3/round-1/test-reviewer-deepseek.md @@ -0,0 +1,33 @@ +# 0.3 — test-reviewer-deepseek — round 1 + +Verdict: approve + +## Previous findings + +### F1 — should-fix: `__init__` config-load registration path untested +- Status: **fixed** by commit 5167241. +- `test_config_loaded_broadcaster_instrument_gets_sink` (added in 5167241) constructs a + `StationServer` directly with a `serverConfig`/`stationConfig` naming a + `DummyBroadcasterInstrument` with `initialize: True`, and asserts the config-loaded + component carries `server._broadcastParameterChange` in `_broadcast_sinks` (length 1). + Removing the `__init__` loop (`core.py:151-152`) leaves `_broadcast_sinks` empty and fails + the assert, so the test genuinely pins the previously-untested branch. The wire path is + already covered end-to-end by `test_created_broadcaster_instrument_reaches_subclient`. + +## Findings + +No must-fix or should-fix findings in this round. + +- (nit, not sent) The config-loaded test skips emitting through the component and confirming + a `SubClient` receives it; this is justified because a never-started `StationServer` has no + bound PUB socket, and the wire path is independently covered. The registration the + `__init__` loop performs is asserted directly, which is the point of the test. + +## Notes +- The fix commit touches only `test/pytest/test_broadcaster.py`; no `src/` change. Existing + tests are untouched (no deletion, skip, or weakening). +- New test hygiene is sound: uses the `server_port` fixture (no fixed port), cleans up the + temp config file, wakeup socketpair, and closes the `cfg_bcaster` instrument so the + module-scoped server's instruments are undisturbed; the module-level + `_close_instruments_between_modules` fixture also runs `close_all()`. +- Tests run: `uv run pytest test/pytest/test_broadcaster.py` — 12 passed in 7.47s. \ No newline at end of file diff --git a/orchestration/0.3/round-1/test-reviewer-qwen.md b/orchestration/0.3/round-1/test-reviewer-qwen.md new file mode 100644 index 0000000..9a725ce --- /dev/null +++ b/orchestration/0.3/round-1/test-reviewer-qwen.md @@ -0,0 +1,24 @@ +# 0.3 — test-reviewer-qwen — round 1 + +Verdict: approve + +## Previous findings + +- **F1 (must-fix — config-load registration path untested): FIXED.** The fix commit adds `test_config_loaded_broadcaster_instrument_gets_sink` (`test/pytest/test_broadcaster.py:240`), which does exactly what the fix list asked: it writes a station-config YAML defining `cfg_bcaster` → `DummyBroadcasterInstrument` with `initialize: True`, splits it with `loadConfig`, constructs a `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly, and asserts the component exists, `isinstance(component, Broadcaster)`, `server._broadcastParameterChange in component._broadcast_sinks`, and `len(component._broadcast_sinks) == 1`. I verified the `__init__` loop it covers is intact (`core.py:148-152`, unchanged from 04c4cbc), and the test is meaningful: for a config-loaded instrument the only sink-registration path is that loop (`_createInstrument` is not on this path), so removing the loop would leave `_broadcast_sinks` empty and both the membership and length asserts would fail. The coder skipped the optional SubClient emission step with a sound reason (an un-started `StationServer` has no bound PUB socket, so emission would trip the `assert self.broadcastSocket is not None` in `_broadcastParameterChange`); the plan's table requires "server registers sinks for … config-loaded instruments" — registration is what is asserted — and the wire path is covered by `test_created_broadcaster_instrument_reaches_subclient`. The teardown is actually safer than my suggestion: it closes the temp config file, the `__init__` wakeup socketpair, and only the `cfg_bcaster` instrument itself (not `close_all()`), so the module-scoped server's instruments are undisturbed. +- **F2 (nit — "gets no sink" asserted via the class rather than server state): DROPPED by orchestrator** (Round 0 merge: "Not sent: nit"). Still present, unchanged (`test_plain_dummy_instrument_still_works_and_gets_no_sink` line 237). Intentional; no action expected. +- **F3 (nit — `emit_broadcast` return value not asserted): DROPPED by orchestrator** (Round 0 merge: "Not sent: nit"). Still present; the created-path test is unchanged by the fix commit. Intentional; no action expected. + +## Did the fix commit break or weaken anything? + +No. Commit 5167241 touches only `test/pytest/test_broadcaster.py` (new imports `qcodes`, `loadConfig`, `StationServer`, plus the one new test); no `src/` change. I re-verified the `__init__` registration loop is intact at `core.py:148-152` (consistent with the orchestrator's mutation-check record). No existing test was modified, weakened, or skipped — all 11 pre-existing tests in the file still pass. The new test is deterministic (no sockets, no timing, runs last in the file, does not use the module server fixtures), so it adds no flakiness risk and cannot interfere with the module-scoped `start_server`. + +## New findings + +None. The new test is at the right layer (server, unit-style construction of `StationServer`), its name is accurate and in the plan's vocabulary (config, Broadcaster, sink), and it fails if the config-load registration regresses. + +## Notes + +- Tests run: + - `uv run pytest test/pytest/test_broadcaster.py -v` → `12 passed in 7.47s` + - `uv run pytest` (whole suite) → `173 passed, 4 warnings in 66.51s` (the 4 warnings are the same pre-existing `pytest.mark.integration` unknown-mark warnings in `test_apps.py`, unrelated to this commit). +- Both registration points named in task 0.3 now have a dedicated test: created-over-the-wire (`test_created_broadcaster_instrument_reaches_subclient`) and config-loaded (`test_config_loaded_broadcaster_instrument_gets_sink`), matching the plan's Testing table for `test_broadcaster.py`. From 56ece34b31fe06c487557bcbda6057f66b515af2 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 22:26:23 -0500 Subject: [PATCH 014/107] 0.4: fix latent KeyError in parameter-creation broadcast and pass broadcast port to the parameter manager GUI launcher --- src/instrumentserver/apps.py | 10 ++++- src/instrumentserver/server/core.py | 5 ++- test/pytest/test_apps.py | 20 +++++++-- test/pytest/test_param_manager.py | 64 +++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/instrumentserver/apps.py b/src/instrumentserver/apps.py index d705097..f0e05f4 100644 --- a/src/instrumentserver/apps.py +++ b/src/instrumentserver/apps.py @@ -125,7 +125,7 @@ def parameterManagerScript() -> None: description="Starting a parameter manager instrument GUI" ) parser.add_argument("--name", default="parameter_manager") - parser.add_argument("--port", default=5555) + parser.add_argument("--port", default=5555, type=int) args = parser.parse_args() app = QtWidgets.QApplication([]) @@ -142,7 +142,13 @@ def parameterManagerScript() -> None: pm.fromFile() pm.update() - _ = widgetMainWindow(ParameterManagerGui(pm), "Parameter Manager") + # The GUI's broadcast listener must follow the server's broadcast port + # (request port + 1); without it the GUI listens on the default port + # regardless of --port. + _ = widgetMainWindow( + ParameterManagerGui(pm, sub_port=args.port + 1, sub_host="localhost"), + "Parameter Manager", + ) app.exec_() diff --git a/src/instrumentserver/server/core.py b/src/instrumentserver/server/core.py index 65facad..e8d5a9b 100644 --- a/src/instrumentserver/server/core.py +++ b/src/instrumentserver/server/core.py @@ -624,7 +624,10 @@ def _newOrDeleteParameterDetection( if spec.target.split(".")[-1] == "add_parameter": name = spec.target.split(".")[0] + "." + ".".join(spec.args) # type: ignore[arg-type] pb = ParameterBroadcastBluePrint( - name, "parameter-creation", kwargs["initial_value"], kwargs["unit"] + name, + "parameter-creation", + kwargs.get("initial_value"), + kwargs.get("unit", ""), ) self._broadcastParameterChange(pb) elif spec.target.split(".")[-1] == "remove_parameter": diff --git a/test/pytest/test_apps.py b/test/pytest/test_apps.py index 80e2aaf..844de02 100644 --- a/test/pytest/test_apps.py +++ b/test/pytest/test_apps.py @@ -353,7 +353,11 @@ def test_detached_server_script_custom(): def test_param_manager_script_instrument_exists(): - """parameterManagerScript: instrument exists → get_instrument path taken.""" + """parameterManagerScript: instrument exists → get_instrument path taken. + + The GUI must listen for Broadcasts on the server's broadcast port + (request port + 1), not on the default port. + """ sys.argv = ["instrumentserver-param-manager", "--port", "4567"] mock_pm = MagicMock() mock_cli = MagicMock() @@ -373,12 +377,18 @@ def test_param_manager_script_instrument_exists(): mock_cli.get_instrument.assert_called_once_with("parameter_manager") mock_cli.find_or_create_instrument.assert_not_called() - mock_pmg.assert_called_once_with(mock_pm) + mock_pmg.assert_called_once_with( + mock_pm, sub_port=4568, sub_host="localhost" + ) mock_wmw.assert_called_once() def test_param_manager_script_instrument_missing(): - """parameterManagerScript: instrument not found → find_or_create path taken.""" + """parameterManagerScript: instrument not found → find_or_create path taken. + + The GUI must listen for Broadcasts on the server's broadcast port + (request port + 1), not on the default port. + """ sys.argv = ["instrumentserver-param-manager", "--port", "4567"] mock_pm = MagicMock() mock_cli = MagicMock() @@ -402,7 +412,9 @@ def test_param_manager_script_instrument_missing(): mock_cli.get_instrument.assert_not_called() mock_pm.fromFile.assert_called_once() mock_pm.update.assert_called_once() - mock_pmg.assert_called_once_with(mock_pm) + mock_pmg.assert_called_once_with( + mock_pm, sub_port=4568, sub_host="localhost" + ) mock_wmw.assert_called_once() diff --git a/test/pytest/test_param_manager.py b/test/pytest/test_param_manager.py index f0d3a15..b8b1796 100644 --- a/test/pytest/test_param_manager.py +++ b/test/pytest/test_param_manager.py @@ -1,5 +1,10 @@ import json +import time +from contextlib import contextmanager +from instrumentserver import QtCore +from instrumentserver.blueprints import ParameterBroadcastBluePrint +from instrumentserver.client.proxy import SubClient from instrumentserver.params import ParameterGroup, ParameterManager @@ -55,6 +60,65 @@ def test_proxy_add_remove_parameter(param_manager): assert "probe_param" not in params.parameters +@contextmanager +def capture_broadcasts(instruments, sub_port): + """Run a SubClient on its own QThread and collect the Broadcasts it receives. + + Follows the capture pattern of ``test_broadcaster.py``; takes the + Broadcast port from the ``server_port`` fixture instead of the default. + """ + received = [] + sub = SubClient(instruments=instruments, sub_host="localhost", sub_port=sub_port) + sub.update.connect(received.append, QtCore.Qt.DirectConnection) + thread = QtCore.QThread() + sub.moveToThread(thread) + thread.started.connect(sub.connect) + sub.finished.connect(thread.quit) + thread.start() + # PUB/SUB slow joiner: let the SUB socket connect before Broadcasts fire. + time.sleep(0.3) + try: + yield received + finally: + sub.stop() + thread.wait(2000) + thread.deleteLater() + + +def wait_for_broadcasts(received, n=1, timeout=5.0): + """Block until at least ``n`` Broadcasts arrived, or fail with a report.""" + deadline = time.monotonic() + timeout + while len(received) < n: + if time.monotonic() > deadline: + raise AssertionError( + f"Expected {n} Broadcast(s) within {timeout}s, " + f"got {len(received)}: {received!r}" + ) + time.sleep(0.05) + + +def test_add_parameter_without_initial_value_succeeds_and_broadcasts( + param_manager, server_port +): + """Calling ``add_parameter("x")`` with no initial_value and no unit over + the wire succeeds and Broadcasts the creation with an empty payload + (the Server's detection must not raise a latent KeyError).""" + cli, params = param_manager + + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + params.add_parameter("x") + wait_for_broadcasts(received) + + assert "x" in params.parameters + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.x" + assert bp.action == "parameter-creation" + assert bp.value is None + assert bp.unit == "" + + def test_removing_all_params(): params = ParameterManager(name="params") From 884558a14b1944d0443fc23fb712f5a4a63c6427 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 23:23:24 -0500 Subject: [PATCH 015/107] 0.4: orchestration record --- PLAN_parameter_manager_redesign.md | 2 +- orchestration/0.4/decisions.md | 49 ++++++++++++ orchestration/0.4/round-0/fix-list.md | 3 + .../0.4/round-0/plan-checker-deepseek.md | 10 +++ .../0.4/round-0/plan-checker-qwen.md | 80 +++++++++++++++++++ .../0.4/round-0/reviewer-deepseek.md | 21 +++++ orchestration/0.4/round-0/reviewer-qwen.md | 23 ++++++ .../0.4/round-0/test-reviewer-deepseek.md | 17 ++++ .../0.4/round-0/test-reviewer-qwen.md | 28 +++++++ 9 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 orchestration/0.4/decisions.md create mode 100644 orchestration/0.4/round-0/fix-list.md create mode 100644 orchestration/0.4/round-0/plan-checker-deepseek.md create mode 100644 orchestration/0.4/round-0/plan-checker-qwen.md create mode 100644 orchestration/0.4/round-0/reviewer-deepseek.md create mode 100644 orchestration/0.4/round-0/reviewer-qwen.md create mode 100644 orchestration/0.4/round-0/test-reviewer-deepseek.md create mode 100644 orchestration/0.4/round-0/test-reviewer-qwen.md diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index df6843e..6e81b89 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -428,7 +428,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session rename. Tests: `test_broadcaster.py` server part — a dummy `Broadcaster` instrument created through `cli.find_or_create_instrument`; calling a method on it that emits a blueprint is received by a `SubClient`; a plain dummy instrument still works and gets no sink. -- [ ] **0.4 Pre-existing fixes (D24, first two).** `_newOrDeleteParameterDetection`: use +- [x] **0.4 Pre-existing fixes (D24, first two).** `_newOrDeleteParameterDetection`: use `kwargs.get("initial_value")` / `kwargs.get("unit", "")`. `apps.py:parameterManagerScript`: pass `sub_port=args.port + 1` (and `sub_host="localhost"`) into `ParameterManagerGui`. Tests: `test_apps.py` (extend the two existing param-manager launcher tests to assert diff --git a/orchestration/0.4/decisions.md b/orchestration/0.4/decisions.md new file mode 100644 index 0000000..f2c41a6 --- /dev/null +++ b/orchestration/0.4/decisions.md @@ -0,0 +1,49 @@ +# 0.4 Pre-existing fixes (D24, first two) — decisions log + +Run: run_e6f4c00ea2df. Branch: marcosfrenkel/new-param-manager. Base commit: 88eeda0978cae2f3aa5ec76bac7c50444fb6bd85. + +## Workers + +| agent id | terminal handle | current dispatch id | +|---|---|---| +| plan-checker-qwen | term_7da87644-1284-4941-a634-0c49ef2fed22 | ctx_32138e7e96b9 (task_dc2d726e35aa, round 0) | +| plan-checker-deepseek | term_d339209a-287a-406c-b55d-dec40d32885d | ctx_0f25b3317a4d (task_27e70ad56463, round 0) | +| test-reviewer-qwen | term_68349c14-9cec-4e9a-b7d6-5bf81057dccb | ctx_9dc81465d35c (task_a2cc5b175f7f, round 0) | +| test-reviewer-deepseek | term_ecefb21d-50b0-4e4c-b311-700961f290c6 | ctx_5ddc99b46da4 (task_5d5d55a85fe4, round 0) | +| reviewer-qwen | term_e2ed50a3-68d0-4322-8c96-acf88fd9c980 | ctx_3251bebf2f6c (task_1221a2fc837e, round 0) | +| reviewer-deepseek | term_83491358-911e-447e-8029-7a6aabc0d3d5 | ctx_edd8b4b63a12 (task_74070a014957, round 0) | +| coder | term_153dd2fe-6067-44c9-9e20-ea12cc6d8e43 | ctx_1d64da13fd1e (task_72a86c9e1394, first implementation) | + +## Log + +- Checkbox 0.4 set to [~]. Base 88eeda0. +- Coder dispatched for first implementation (task_72a86c9e1394 / ctx_1d64da13fd1e). +- Permission: coder asked python/uv import qcodes location + ls of venv dirs (read-only). Allowed once. +- Coder worker_done (succeeded). Commit 56ece34 "0.4: fix latent KeyError in parameter-creation broadcast and pass broadcast port to the parameter manager GUI launcher"; files: src/instrumentserver/apps.py, src/instrumentserver/server/core.py, test/pytest/test_apps.py, test/pytest/test_param_manager.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files, nothing dirty outside orchestration/ and the plan. Coder retained. +- Coder judgment call: added `type=int` to the launcher's `--port` argparse argument so `args.port + 1` (the plan's prescribed expression) works; before, the port reached Client as a string. Orchestrator view: required by the task text; reviewers will judge. +- Orchestrator tests: `uv run pytest -q test/pytest/test_apps.py test/pytest/test_param_manager.py` -> 31 passed in 12.28s; `uv run pytest -q` -> 174 passed, 4 warnings in 68.94s. +- Six reviewers dispatched for round 0 (target 88eeda0..56ece34). +- test-reviewer-qwen worker_done (succeeded, approve, 2 nits). Retained. +- plan-checker-qwen worker_done (succeeded, approve, 0 findings; type=int judged in scope). Retained. +- reviewer-qwen worker_done (succeeded, approve, 1 nit: duplicated capture helper). Retained. +- plan-checker-deepseek and test-reviewer-deepseek stalled on provider 'Upstream error'; reviewer-deepseek degenerated into garbled output. No reports. All three nudged in their terminals to resume and report. +- Correction: the first nudge never reached the three terminals (orchestrator shell bug: empty handle). Re-sent successfully ~10 min later. +- Permission: plan-checker-deepseek asked access to a garbled '/Users:/Users/...' path outside the repo. REJECTED; told it to use the relative report path. +- Permission: plan-checker-deepseek asked rm of its own report file orchestration/0.4/round-0/plan-checker-deepseek.md to rewrite it. Allowed once (its own file, under orchestration/). +- Round 0 deepseek reviewers after the nudge: reviewer-deepseek wrote a complete report (approve, 1 nit) but hit a provider error before worker_done; test-reviewer-deepseek's report has approve + 1 nit but a garbled Notes tail; plan-checker-deepseek wrote only a skeleton (approve, no findings). All three nudged again with specific instructions. +- plan-checker-deepseek worker_done after nudges (succeeded, approve, 0 findings). Retained. +- test-reviewer-deepseek worker_done after nudges (succeeded, approve, 1 nit). Retained. +- reviewer-deepseek worker_done after nudges (succeeded, approve, 1 nit). Retained. All six round-0 reports present. + +## Round 0 merge (six reports, all `approve`; 0 must-fix / 0 should-fix) + +- reviewer-qwen F1 + test-reviewer-qwen F1 + reviewer-deepseek note: `capture_broadcasts`/`wait_for_broadcasts` copied verbatim from test_broadcaster.py into test_param_manager.py. Nit by all who raised it; not a plan rule. Not sent: nit. Worth consolidating into conftest.py when a third copy appears (tasks 1.3 / 2.5). +- test-reviewer-qwen F2 + test-reviewer-deepseek F1: launcher tests only exercise the `--port 4567` path, not the default-port path. Not sent: nit; the plan asks only to extend the two existing tests, which was done. +- reviewer-deepseek F1 (nit): `type=int` on `--port` is a judgment call beyond D24's literal text; every reviewer (both plan-checkers explicitly) judges it in scope as a prerequisite for `args.port + 1`. Not sent; accepted. +- Fix list: EMPTY. Task goes to finish. + +## Finish +- All seven workers released (Orca: state retained, processAction none) and their terminals closed. `worker-list --terminal-state reclaimable` for run_e6f4c00ea2df: 0 rows. +- Checkbox 0.4 set to [x]. + +**Summary.** Outcome: done. Commits: `56ece34 0.4: fix latent KeyError in parameter-creation broadcast and pass broadcast port to the parameter manager GUI launcher`. Fix rounds used: 0. Tests (orchestrator): `uv run pytest -q test/pytest/test_apps.py test/pytest/test_param_manager.py` -> 31 passed in 12.28s; `uv run pytest -q` -> 174 passed, 4 warnings in 68.94s. diff --git a/orchestration/0.4/round-0/fix-list.md b/orchestration/0.4/round-0/fix-list.md new file mode 100644 index 0000000..0b40a33 --- /dev/null +++ b/orchestration/0.4/round-0/fix-list.md @@ -0,0 +1,3 @@ +# 0.4 — fix list from round 0 + +Empty. All six reviewers approve; every finding is a nit (not sent). diff --git a/orchestration/0.4/round-0/plan-checker-deepseek.md b/orchestration/0.4/round-0/plan-checker-deepseek.md new file mode 100644 index 0000000..12f0f4e --- /dev/null +++ b/orchestration/0.4/round-0/plan-checker-deepseek.md @@ -0,0 +1,10 @@ +# 0.4 - plan-checker-deepseek - round 0 +Verdict: approve +## Findings +None. + +## Notes +- Scope: changes limited to apps.py, server/core.py, test_apps.py, test_param_manager.py; single commit 56ece34. +- Plan rule checks: both D24 fixes applied as specified; glossary terms respected; named tests present. +- type=int addition: necessary enabler for args.port + 1 and within scope, not a scope widening. +- Tests run: 31 passed for the two named files; full suite 174 passed. \ No newline at end of file diff --git a/orchestration/0.4/round-0/plan-checker-qwen.md b/orchestration/0.4/round-0/plan-checker-qwen.md new file mode 100644 index 0000000..64e4936 --- /dev/null +++ b/orchestration/0.4/round-0/plan-checker-qwen.md @@ -0,0 +1,80 @@ +# 0.4 — plan-checker-qwen — round 0 + +Verdict: approve + +## Findings + +(None.) + +## Notes + +- Commit 56ece34 is a single commit touching exactly the four files task 0.4 names: + `src/instrumentserver/apps.py`, `src/instrumentserver/server/core.py`, + `test/pytest/test_apps.py`, `test/pytest/test_param_manager.py`. Commit message + starts with `0.4:` per session protocol step 6. + +- **Acceptance, point by point** (task text: "0.4 Pre-existing fixes (D24, first two)"): + 1. `_newOrDeleteParameterDetection` now uses `kwargs.get("initial_value")` and + `kwargs.get("unit", "")` (server/core.py:629-630) — exactly as specified. + 2. `parameterManagerScript` passes `sub_port=args.port + 1, sub_host="localhost"` + into `ParameterManagerGui` (apps.py:149) — exactly as specified. I verified the + kwargs are real: `ParameterManagerGui.__init__` forwards `**kwargs` to + `InstrumentParameters`, which pops `sub_host`/`sub_port` into `ModelParameters`, + which hands them to `SubClient` (gui/instruments.py:413-424, 555-558). + 3. Both existing param-manager launcher tests in `test_apps.py` + (`test_param_manager_script_instrument_exists`, + `test_param_manager_script_instrument_missing`) are extended to assert + `mock_pmg.assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost")` + for `--port 4567`. + 4. The named proxy test exists: + `test_add_parameter_without_initial_value_succeeds_and_broadcasts` in + `test_param_manager.py`, using the `param_manager` proxy fixture against the live + server. It calls `params.add_parameter("x")` with no `initial_value`/`unit`, asserts + success, exactly one Broadcast, and `bp.action == "parameter-creation"`, + `bp.value is None`, `bp.unit == ""`. + +- **The `type=int` judgment call is in scope, not scope creep.** Task 0.4 specifies + `sub_port=args.port + 1`; argparse without `type=` yields a string for a CLI-passed + `--port`, so `args.port + 1` would raise `TypeError` (and the task's own tests use + `--port 4567` and assert the int `sub_port=4568`, which cannot pass without it). The + change is the minimum needed to make the specified expression work, matches + `Client.__init__(port: int)` (client/proxy.py:453), and touches only the + `parameterManagerScript` parser — `serverScript`, `detachedServerScript` and + `clientStationScript` keep their existing string-port behaviour, which rule 6 ("Do + not widen scope. Pre-existing defects not listed in Phase 0 are noted in + `TEST_AUDIT.md`, not fixed.") requires leaving alone. The pinned test + `test_server_script_passthrough_args` still asserts `kwargs["port"] == "9999"` and + passes. + +- **D24 item three untouched.** `ParameterManagerTreeView.onItemNewValue` + (gui/instruments.py) is not modified — correct, it belongs to task 5.1 ("Fix D24 item + three: `ParameterManagerTreeView.onItemNewValue` uses `widget._setMethod(value)`"). + No work from other tasks appears in the commit. + +- **The proxy test can fail on unfixed code.** Before the fix, `kwargs["initial_value"]` + raised `KeyError` after `obj(*args, **kwargs)` in `_invoke()`, so the server answered + with `ServerResponse(error=...)` and `BaseClient.ask` (default + `raise_exceptions=True`) would raise from `params.add_parameter("x")`. The new test + therefore fails pre-fix and passes post-fix; it is a genuine regression test, and it + also pins the wire format (value serialises as `"None"` → `None` on + deserialisation; `""` round-trips as `""` — verified against + `bluePrintToDict`/`deserialize_obj` in blueprints.py). + +- **Vocabulary.** New test names, docstrings and comments use "Broadcast" / "Broadcasts" + per CONTEXT.md; the helpers `capture_broadcasts` / `wait_for_broadcasts` mirror the + identically named helpers already committed in task 0.3's `test_broadcaster.py`. + Action string `"parameter-creation"` stays a literal, as D10 requires ("Action strings + stay strings; module-level constants for all actions" — the constants are task 0.5). + +- **Decisions and ADRs.** Consistent with ADR-0003 ("Direct `add_parameter` / + `remove_parameter` calls keep being announced by the Server"), D24, and D27 (the + SubClient port comes from the `server_port` fixture, `server_port + 1`, never a fixed + port). Rule 7 protected APIs: no signature or behaviour change to `add_parameter` or + any other listed method. + +- **Tests run.** + - `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py -v` → + `31 passed in 12.47s`. + - `uv run pytest` (whole suite) → `174 passed, 4 warnings in 68.71s`. The 4 warnings + are pre-existing `PytestUnknownMarkWarning`s for `@pytest.mark.integration` in + `test_apps.py`, unrelated to this commit. diff --git a/orchestration/0.4/round-0/reviewer-deepseek.md b/orchestration/0.4/round-0/reviewer-deepseek.md new file mode 100644 index 0000000..ff0fc42 --- /dev/null +++ b/orchestration/0.4/round-0/reviewer-deepseek.md @@ -0,0 +1,21 @@ +# 0.4 — reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/test_param_manager.py:62 +- What: The `capture_broadcasts` / `wait_for_broadcasts` helpers duplicate the capture pattern already in `test_broadcaster.py`. +- Why: Pure test-side duplication; not production dead code and not in the general reviewer's lane to enforce. +- Suggested fix: Optionally factor a shared capture helper into `conftest.py` when later tasks (2.5, 3.2) also need broadcast capture. Not required for this task. + +## Notes + +- `_newOrDeleteParameterDetection` (server/core.py:624-631) now reads `kwargs.get("initial_value")` / `kwargs.get("unit", "")`, fixing the latent `KeyError` that D24 lists. When the args are absent the broadcast carries `value=None` / `unit=""`, which match `ParameterBroadcastBluePrint`'s own defaults (`value: int | None = None`, `unit: str = ""`). +- `apps.py:parameterManagerScript` passes `sub_port=args.port + 1, sub_host="localhost"` into `ParameterManagerGui`. Wiring verified end to end: `ParameterManagerGui.__init__` forwards `**kwargs` to `InstrumentParameters.__init__`, which pops `sub_port`/`sub_host` into `modelKwargs` (instruments.py:555-558), consumed by `ModelParameters` for its `SubClient` (instruments.py:413-424). `args.port + 1` matches the server's broadcast-port convention (server binds `port`, broadcasts on `port + 1`), so the GUI now follows `--port`. +- **Judgement on `type=int`:** it is required for the plan-mandated `args.port + 1` to be valid arithmetic — `args.port` would otherwise stay a `str` and `args.port + 1` would raise `TypeError`. It also makes the CLI match `Client(port: int)`'s signature (client/core.py:32). This is a necessary prerequisite for the task's specified change, not a scope-widening extra fix, so it does not violate plan rule 6 ("Do not widen scope"). Side effect: argparse now rejects a non-integer `--port`, which is strictly more correct. +- Caller context (server/core.py:483-487): `_newOrDeleteParameterDetection` runs after the call; the fix only changes how the broadcast payload is built, not the add/remove flow. +- Tests: `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py -q` → `31 passed in 12.42s`. The two launcher tests assert `sub_port=4568, sub_host="localhost"`; the new proxy test `test_add_parameter_without_initial_value_succeeds_and_broadcasts` covers the no-`initial_value`/`unit` path over the wire using `server_port + 1`. + +Verdict is approve: no must-fix or should-fix findings. \ No newline at end of file diff --git a/orchestration/0.4/round-0/reviewer-qwen.md b/orchestration/0.4/round-0/reviewer-qwen.md new file mode 100644 index 0000000..304604a --- /dev/null +++ b/orchestration/0.4/round-0/reviewer-qwen.md @@ -0,0 +1,23 @@ +# 0.4 — reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/test_param_manager.py:63-97 +- What: `capture_broadcasts` and `wait_for_broadcasts` are copied verbatim (~35 lines) from test/pytest/test_broadcaster.py:166-202 (only the docstring's cross-reference differs). +- Why: Two identical copies of a non-trivial QThread + PUB/SUB slow-joiner helper can drift; the plan adds further proxy broadcast tests in tasks 1.3 and 2.5 (test_pm_locks.py, test_pm_types.py), so the duplication is likely to multiply. Not a correctness issue today — both copies pass — and cross-importing between test modules is fragile (a module is only importable when its file was collected), which is probably why the coder copied instead. +- Suggested fix: If desired, move both helpers into test/pytest/conftest.py so every module gets one copy; otherwise leave as is and let the first task that adds a third copy consolidate. + +## Notes + +- Tests run: + - `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py` → `31 passed in 12.46s`. + - `uv run pytest` (whole suite) → `174 passed, 4 warnings in 69.14s`. All 4 warnings are pre-existing `PytestUnknownMarkWarning` for the unregistered `integration` mark in the old test_apps.py integration tests; not introduced by this commit. +- Server fix (src/instrumentserver/server/core.py:624-630): matches D24 verbatim. `kwargs.get("initial_value")` yields `None` and `kwargs.get("unit", "")` yields `""`, which are exactly the `ParameterBroadcastBluePrint` dataclass defaults (blueprints.py:362-363), so the empty-payload broadcast is well-formed. `None` round-trips through the wire format: `bluePrintToDict` → `json.dumps` (null) → `deserialize_obj` returns `None` for null values (blueprints.py:935-936), confirmed by the passing proxy test asserting `bp.value is None`. +- Positional-args edge case considered and dismissed: `_newOrDeleteParameterDetection` reads only `kwargs`, so a positional `add_parameter("x", 5, "V")` would broadcast `value=None, unit=""`. This cannot happen for the Parameter Manager in practice: `ParameterManager.add_parameter(name, **kw)` is keyword-only (params.py:123), so a positional call raises `TypeError` on the server before the detection runs (server/core.py:484) — no half state, no wrong payload. All real callers use kwargs (GUI: gui/instruments.py:830-833; proxy: client/proxy.py:290). D24 prescribes exactly this form, so no finding. +- Launcher fix (src/instrumentserver/apps.py:145-151): verified the kwargs chain. `ParameterManagerGui.__init__` forwards `**kwargs` to `InstrumentParameters.__init__`, which pops `sub_host`/`sub_port` into the model kwargs (gui/instruments.py:756-772, 555-558) and they reach `SubClient` (client/proxy.py:681-697). `sub_host="localhost"` matches the implicit host of `Client(port=args.port)` (client/proxy.py:452 defaults host to "localhost"), so the two stay consistent. +- Judgment call on `type=int` (apps.py:128): correct, and within D24's scope. Without it, argparse returns `--port` as a string, and `args.port + 1` would raise `TypeError` for every explicit `--port` — precisely the custom-port path D24 exists to fix (the no-flag default `5555` is the only case that would have worked). It also aligns `parameterManagerScript` with `clientStationScript`, which already uses `type=int` for `--port` (apps.py:173). No existing behaviour breaks: `BaseClient` only interpolates the port into an f-string address (client/core.py:43), so int and str ports behave identically on the wire; the only behavioural change is that invalid input (`--port abc`) now fails at parse time with a clear argparse error instead of later at connect. `serverScript`/`detachedServerScript` still have string-typed `--port` (apps.py:45, 161) — pre-existing, explicitly outside D24 (which names only the parameter-manager launcher), and pinned by existing tests that assert string values (`port == "9999"`, `port="9000"` in test_apps.py:269, 345-347); correctly left untouched. +- Both extended launcher tests (test_apps.py:355-418) assert the exact kwargs `sub_port=4568, sub_host="localhost"` and would fail against the pre-fix code; the new proxy test would also fail pre-fix (the latent KeyError surfaces as a failed remote call) and on a wrong sub-port (timeout in `wait_for_broadcasts`), so the named tests can fail and pin the behaviour. +- New proxy test uses the module-scoped `server_port` fixture per D27 (`server_port + 1` for the broadcast port), no fixed ports; the parameter name `"x"` does not collide with parameters created by earlier tests in the module. diff --git a/orchestration/0.4/round-0/test-reviewer-deepseek.md b/orchestration/0.4/round-0/test-reviewer-deepseek.md new file mode 100644 index 0000000..ec2c2ae --- /dev/null +++ b/orchestration/0.4/round-0/test-reviewer-deepseek.md @@ -0,0 +1,17 @@ +# 0.4 — test-reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/test_apps.py:355, :386 (the two param-manager launcher tests) +- What: The two launcher unit tests pass an already-integer port (4567) directly through `sys.argv`, so they exercise the argparse parser only for `sub_port = args.port + 1` behaviour (asserting `sub_port=4568`), not the string-to-int conversion itself. +- Why: This is a harmless gap. If `--port` stayed a string, `args.port + 1` would yield `"45671"` and both asserts would still fail, so the tests do catch a missing `type=int`. The coder's `type=int` addition is a judgment call outside the plan's literal wording, but it is effectively required for `args.port + 1` to work and the existing asserts would catch its regression. Not worth sending to the coder. +- Suggested fix: none. + +## Notes +- Plan task 0.4 requires exactly the three things implemented: (1) `kwargs.get("initial_value")` / `kwargs.get("unit", "")` in `_newOrDeleteParameterDetection` (src/instrumentserver/server/core.py:627-631); (2) `sub_port=args.port + 1`, `sub_host="localhost"` into `ParameterManagerGui` (src/instrumentserver/apps.py:146-152); (3) the named tests — `test_apps.py` extended (asserts `sub_port=4568, sub_host="localhost"` in both launcher paths) and the new proxy test in `test_param_manager.py`. +- New proxy test `test_add_parameter_without_initial_value_succeeds_and_broadcasts`: correct layer (uses the `param_manager` proxy fixture over the real server plus a `SubClient` on `server_port + 1`), and meaningful — without the fix the server-side `kwargs["initial_value"]` raises a `KeyError` inside `_invoke()`, breaking the client call, so the test fails against the old code. It asserts parameter existence, exactly one broadcast, `name == "parameter_manager.x"`, `action == "parameter-creation"`, `value is None`, `unit == ""`. +- Broadcast port convention matches production: the server binds `port` for requests and broadcasts on `port + 1` (conftest `server_port` docstring), so `sub_port=server_port + 1` is correct. +- Tests run: `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py -q` → 31 passed in 12.28s. \ No newline at end of file diff --git a/orchestration/0.4/round-0/test-reviewer-qwen.md b/orchestration/0.4/round-0/test-reviewer-qwen.md new file mode 100644 index 0000000..f58aa1b --- /dev/null +++ b/orchestration/0.4/round-0/test-reviewer-qwen.md @@ -0,0 +1,28 @@ +# 0.4 — test-reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: test/pytest/test_param_manager.py:63-97 +- What: `capture_broadcasts` and `wait_for_broadcasts` are copied verbatim from test_broadcaster.py:166-202. +- Why: two identical SubClient-capture helpers now live in two test modules; if the capture pattern changes (e.g. thread join, slow-joiner delay) both copies must be updated. Not a plan rule — the plan's Testing section does not require a shared helper, and the coder's docstring notes the pattern is deliberately followed. +- Suggested fix: none required for this task; if a third module needs it (test_pm_locks.py / test_pm_types.py), factor it into a shared test helper. + +### F2 — nit +- Where: test/pytest/test_apps.py:355, 386 +- What: both launcher tests pass `--port 4567` on argv; the default-port path (`args.port = 5555` → `sub_port=5556`) is not exercised. +- Why: the plan only names "extend the two existing param-manager launcher tests to assert the kwargs", which is done; the default-port branch is the same one code line and the argparse default was already an int, so the risk of an uncaught regression there is negligible. +- Suggested fix: optionally add a third launcher test without `--port` asserting `sub_port=5556`; not blocking. + +## Notes + +- Tests run: `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py` → `31 passed in 12.61s`. Full suite: `uv run pytest` → `174 passed, 4 warnings in 69.09s` (the 4 warnings are pre-existing `PytestUnknownMarkWarning` for the `integration` mark, unrelated to this commit). +- Plan's named tests, all present and meaningful: + - `test_apps.py` — the two existing param-manager launcher tests (`test_param_manager_script_instrument_exists`, `test_param_manager_script_instrument_missing`) were extended to assert `ParameterManagerGui` is called with `sub_port=4568, sub_host="localhost"`. They would fail if the launcher regressed: the old `ParameterManagerGui(pm)` call does not match `assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost")`, and (see below) removing `type=int` makes `args.port + 1` a `TypeError` that errors the test before the assertion. + - `test_param_manager.py` — `test_add_parameter_without_initial_value_succeeds_and_broadcasts` is the named proxy test: via the `param_manager` fixture it calls `add_parameter("x")` with no `initial_value`/`unit`, then asserts exactly one `parameter-creation` broadcast on `server_port + 1` with `name="parameter_manager.x"`, `value is None`, `unit == ""`, and that `"x" in params.parameters`. I traced the broken-code path to confirm it is not vacuous: with the pre-fix `kwargs["initial_value"]` the KeyError is raised server-side after the call succeeds, wrapped in `ServerResponse(error=...)` by `executeServerInstruction`, and re-raised client-side by `BaseClient._handle_server_error` (default `raise_exceptions=True`) — so `params.add_parameter("x")` raises and the test fails; even if the exception were swallowed, no broadcast is emitted (the KeyError aborts before `_broadcastParameterChange`) and `wait_for_broadcasts` times out. The test pins the fix in both directions (call succeeds, and the exact broadcast payload). +- Right layers: launcher kwargs are checked in mocked unit tests (the established Phase-3 pattern in test_apps.py, no Qt event loop, no server); the KeyError fix is checked at the proxy layer through a live server and a real `SubClient`, matching the plan's "proxy tests use the `param_manager` fixture" convention. Names are accurate and in the plan's vocabulary. +- Coder's judgment call (`type=int` on `--port` in apps.py): consistent with the plan's scope rule. D24 requires `sub_port = args.port + 1`, which is uncomputable for a string port; before the commit only the int default `5555` worked and any `--port ` would have crashed the launcher at `args.port + 1`. The change is minimal, is pinned by the two launcher tests (they pass the string `"4567"` on argv and assert the int `sub_port=4568`), and does not touch any other launcher. `Client(port=...)` already receives ints elsewhere (conftest passes the int `server_port` fixture), so nothing downstream breaks. +- No existing tests were weakened, deleted or skipped; both launcher tests kept their original assertions and gained the kwargs check. +- Edge cases: `add_parameter` with `initial_value` present but `unit` missing (or vice versa) shares the same `kwargs.get` path and was equally broken pre-fix; the plan names only the both-missing proxy test, and the present-with-both case is already covered by the pre-existing `test_proxy_add_remove_parameter`. No gap. From b3e6586f09392a036ec508bd5465b275068e9e68 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 23 Sep 2026 23:26:35 -0500 Subject: [PATCH 016/107] 0.5: broadcast action constants in blueprints.py, used at every literal site in server, gui and client application --- src/instrumentserver/blueprints.py | 10 ++++++++++ src/instrumentserver/client/application.py | 4 ++-- src/instrumentserver/gui/instruments.py | 14 ++++++++++---- src/instrumentserver/server/core.py | 12 ++++++++---- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/instrumentserver/blueprints.py b/src/instrumentserver/blueprints.py index 7cbcd54..08c0012 100644 --- a/src/instrumentserver/blueprints.py +++ b/src/instrumentserver/blueprints.py @@ -78,6 +78,16 @@ ParameterType = Union[Parameter, ParameterWithSetpoints] +# Action strings carried in ParameterBroadcastBluePrint.action. These are the +# exact strings on the wire; PM_LOCK_UPDATE and PM_TYPE_UPDATE are emitted by +# instruments implementing the Broadcaster contract. +PARAMETER_UPDATE = "parameter-update" +PARAMETER_CALL = "parameter-call" +PARAMETER_CREATION = "parameter-creation" +PARAMETER_DELETION = "parameter-deletion" +PM_LOCK_UPDATE = "pm-lock-update" +PM_TYPE_UPDATE = "pm-type-update" + @dataclass class ParameterBluePrint: diff --git a/src/instrumentserver/client/application.py b/src/instrumentserver/client/application.py index 9c04582..df5d806 100644 --- a/src/instrumentserver/client/application.py +++ b/src/instrumentserver/client/application.py @@ -8,7 +8,7 @@ from qtpy.QtWidgets import QFileDialog, QWidget from instrumentserver import QtCore, QtGui, QtWidgets, getInstrumentserverPath -from instrumentserver.blueprints import ParameterBroadcastBluePrint +from instrumentserver.blueprints import PARAMETER_UPDATE, ParameterBroadcastBluePrint from instrumentserver.client import ClientStation from instrumentserver.client.proxy import SubClient from instrumentserver.gui.instruments import GenericInstrument @@ -184,7 +184,7 @@ def __init__(self, station: ClientStation): @QtCore.Slot(ParameterBroadcastBluePrint) def listenerEvent(self, message: ParameterBroadcastBluePrint) -> None: - if message.action == "parameter-update": + if message.action == PARAMETER_UPDATE: logger.info(f"{message.action}: {message.name}: {message.value}") def openInstrumentTab(self, item: QtWidgets.QTreeWidgetItem, index: int) -> None: diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 9ef0a47..682f912 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -7,7 +7,13 @@ from instrumentserver.gui.misc import AlertLabelGreen from .. import DEFAULT_PORT, QtCore, QtGui, QtWidgets -from ..blueprints import ParameterBroadcastBluePrint +from ..blueprints import ( + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, + ParameterBroadcastBluePrint, +) from ..client import ProxyInstrument, SubClient from ..helpers import nestedAttributeFromString from ..params import ParameterManager, ParameterTypes, parameterTypes, paramTypeFromName @@ -442,7 +448,7 @@ def stopListener(self) -> None: def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: fullName = ".".join(bp.name.split(".")[1:]) - if bp.action == "parameter-creation": + if bp.action == PARAMETER_CREATION: if fullName not in self.instrument.list(): self.instrument.update() if fullName in self.instrument.list(): @@ -451,10 +457,10 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: element=nestedAttributeFromString(self.instrument, fullName), ) - elif bp.action == "parameter-deletion": + elif bp.action == PARAMETER_DELETION: self.removeItem(fullName) - elif bp.action == "parameter-update" or bp.action == "parameter-call": + elif bp.action == PARAMETER_UPDATE or bp.action == PARAMETER_CALL: item = self.findItems( fullName, cast( diff --git a/src/instrumentserver/server/core.py b/src/instrumentserver/server/core.py index e8d5a9b..2115730 100644 --- a/src/instrumentserver/server/core.py +++ b/src/instrumentserver/server/core.py @@ -38,6 +38,10 @@ from ..blueprints import ( INSTRUMENT_MODULE_BASE_CLASSES, PARAMETER_BASE_CLASSES, + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, CallSpec, InstrumentCreationSpec, InstrumentModuleBluePrint, @@ -493,7 +497,7 @@ def _invoke() -> Any: # Broadcast changes in parameter values. self._broadcastParameterChange( ParameterBroadcastBluePrint( - spec.target, "parameter-update", args[0] + spec.target, PARAMETER_UPDATE, args[0] ) ) else: @@ -501,7 +505,7 @@ def _invoke() -> Any: # Broadcast calls of parameters. self._broadcastParameterChange( - ParameterBroadcastBluePrint(spec.target, "parameter-call", ret) + ParameterBroadcastBluePrint(spec.target, PARAMETER_CALL, ret) ) else: self.funcCalled.emit(spec.target, args, kwargs, ret) @@ -625,14 +629,14 @@ def _newOrDeleteParameterDetection( name = spec.target.split(".")[0] + "." + ".".join(spec.args) # type: ignore[arg-type] pb = ParameterBroadcastBluePrint( name, - "parameter-creation", + PARAMETER_CREATION, kwargs.get("initial_value"), kwargs.get("unit", ""), ) self._broadcastParameterChange(pb) elif spec.target.split(".")[-1] == "remove_parameter": name = spec.target.split(".")[0] + "." + ".".join(spec.args) # type: ignore[arg-type] - pb = ParameterBroadcastBluePrint(name, "parameter-deletion") + pb = ParameterBroadcastBluePrint(name, PARAMETER_DELETION) self._broadcastParameterChange(pb) def _get_lock_for_target(self, target: str) -> Optional[threading.RLock]: From eec0c25415fdad2cca66c4d772b2a5d1e8b7626b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 00:13:51 -0500 Subject: [PATCH 017/107] 0.5: fix from review round 1: pin the broadcast action constants' wire values in a unit test --- test/pytest/test_broadcaster.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py index cf7c688..efa73a4 100644 --- a/test/pytest/test_broadcaster.py +++ b/test/pytest/test_broadcaster.py @@ -17,7 +17,15 @@ from instrumentserver import QtCore from instrumentserver.base import Broadcaster -from instrumentserver.blueprints import ParameterBroadcastBluePrint +from instrumentserver.blueprints import ( + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, +) from instrumentserver.client.proxy import SubClient from instrumentserver.config import loadConfig from instrumentserver.params import ParameterManager @@ -122,6 +130,28 @@ def unknown_sink(bp): bc.remove_broadcast_sink(unknown_sink) # must not raise +# --------------------------------------------------------------------------- +# Broadcast action strings +# (the constants every emitter and consumer shares; their values are the +# wire contract, so they are pinned here) +# --------------------------------------------------------------------------- + + +def test_broadcast_action_constants_pin_the_wire_strings(): + """The action of a Broadcast is a plain string on the wire, and external + subscribers parse these exact strings unchanged (ADR-0003). Every + in-repo emitter and consumer now shares the constants, so the whole + suite would pass even if a constant's value drifted; pinning the values + here keeps that drift from silently breaking external subscribers. + """ + assert PARAMETER_UPDATE == "parameter-update" + assert PARAMETER_CALL == "parameter-call" + assert PARAMETER_CREATION == "parameter-creation" + assert PARAMETER_DELETION == "parameter-deletion" + assert PM_LOCK_UPDATE == "pm-lock-update" + assert PM_TYPE_UPDATE == "pm-type-update" + + # --------------------------------------------------------------------------- # Parameter Manager implements the Broadcaster contract # (it emits nothing on its own yet; here we only prove the mixin machinery From 7ae8e639bfb617a66b8e3729f8a9ddeb666c5d0d Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 00:19:58 -0500 Subject: [PATCH 018/107] 0.5: orchestration record --- PLAN_parameter_manager_redesign.md | 2 +- orchestration/0.5/decisions.md | 83 +++++++++++++++++++ orchestration/0.5/round-0/fix-list.md | 6 ++ .../0.5/round-0/plan-checker-deepseek.md | 38 +++++++++ .../0.5/round-0/plan-checker-qwen.md | 35 ++++++++ .../0.5/round-0/reviewer-deepseek.md | 42 ++++++++++ orchestration/0.5/round-0/reviewer-qwen.md | 30 +++++++ .../0.5/round-0/test-reviewer-deepseek.md | 33 ++++++++ .../0.5/round-0/test-reviewer-qwen.md | 48 +++++++++++ orchestration/0.5/round-1/fix-list.md | 3 + .../0.5/round-1/plan-checker-deepseek.md | 28 +++++++ .../0.5/round-1/plan-checker-qwen.md | 31 +++++++ .../0.5/round-1/reviewer-deepseek.md | 31 +++++++ orchestration/0.5/round-1/reviewer-qwen.md | 26 ++++++ .../0.5/round-1/test-reviewer-deepseek.md | 38 +++++++++ .../0.5/round-1/test-reviewer-qwen.md | 37 +++++++++ 16 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 orchestration/0.5/decisions.md create mode 100644 orchestration/0.5/round-0/fix-list.md create mode 100644 orchestration/0.5/round-0/plan-checker-deepseek.md create mode 100644 orchestration/0.5/round-0/plan-checker-qwen.md create mode 100644 orchestration/0.5/round-0/reviewer-deepseek.md create mode 100644 orchestration/0.5/round-0/reviewer-qwen.md create mode 100644 orchestration/0.5/round-0/test-reviewer-deepseek.md create mode 100644 orchestration/0.5/round-0/test-reviewer-qwen.md create mode 100644 orchestration/0.5/round-1/fix-list.md create mode 100644 orchestration/0.5/round-1/plan-checker-deepseek.md create mode 100644 orchestration/0.5/round-1/plan-checker-qwen.md create mode 100644 orchestration/0.5/round-1/reviewer-deepseek.md create mode 100644 orchestration/0.5/round-1/reviewer-qwen.md create mode 100644 orchestration/0.5/round-1/test-reviewer-deepseek.md create mode 100644 orchestration/0.5/round-1/test-reviewer-qwen.md diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 6e81b89..53bc2f8 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -434,7 +434,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session Tests: `test_apps.py` (extend the two existing param-manager launcher tests to assert the kwargs); a proxy test in `test_param_manager.py` that `add_parameter("x")` with no `initial_value`/`unit` succeeds and broadcasts. -- [ ] **0.5 Broadcast action constants.** In `blueprints.py`: `PARAMETER_UPDATE`, +- [x] **0.5 Broadcast action constants.** In `blueprints.py`: `PARAMETER_UPDATE`, `PARAMETER_CALL`, `PARAMETER_CREATION`, `PARAMETER_DELETION`, `PM_LOCK_UPDATE`, `PM_TYPE_UPDATE` string constants; use them in `server/core.py`, `gui/instruments.py`, `client/application.py`, `monitoring/listener.py` wherever the literals appear (grep diff --git a/orchestration/0.5/decisions.md b/orchestration/0.5/decisions.md new file mode 100644 index 0000000..918a1e1 --- /dev/null +++ b/orchestration/0.5/decisions.md @@ -0,0 +1,83 @@ +# 0.5 Broadcast action constants — decisions log + +Run: run_e6f4c00ea2df. Branch: marcosfrenkel/new-param-manager. Base commit: 884558a14b1944d0443fc23fb712f5a4a63c6427. + +## Workers + +| agent id | terminal handle | current dispatch id | +|---|---|---| +| plan-checker-qwen | term_67098f1f-6389-4754-acd8-0f15458c678c | ctx_b1fe21a35b2e (task_73fdcff54804, round 0) | +| plan-checker-deepseek | term_c40aa02c-a55c-482d-9bca-b9e3d129a3f1 | ctx_183c1dd49fb5 (task_ff5b484b2eb6, round 0) | +| test-reviewer-qwen | term_6c9eb2c2-ba26-4624-b243-ebaf2d246b64 | ctx_a43dde782643 (task_ba174ae1ec81, round 0) | +| test-reviewer-deepseek | term_9ee09d9e-f13e-4a91-8d18-85fbbba3c79a | ctx_8a4f95a54af0 (task_9d8fa0f4573d, round 0) | +| reviewer-qwen | term_40f9b8ed-357a-42f1-b762-433069d06a92 | ctx_3f6cbf7c89e0 (task_7a7b36fb5f99, round 0) | +| reviewer-deepseek | term_18d1e6c4-9a03-4a7d-b81a-9e5ba1e6dd35 | ctx_4f3fa9fe4d15 (task_9707f5bdc29a, round 0) | +| coder | term_6a933bf1-6616-4ea6-8275-dd5aa36436d7 | ctx_82a780643c17 (task_c293e403e57a, first implementation) | + +## Log + +- Checkbox 0.5 set to [~]. Base 884558a. +- Coder dispatched for first implementation (task_c293e403e57a / ctx_82a780643c17). +- Coder worker_done (succeeded). Commit b3e6586 "0.5: broadcast action constants in blueprints.py, used at every literal site in server, gui and client application"; files: blueprints.py, client/application.py, gui/instruments.py, server/core.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files, nothing dirty outside orchestration/ and the plan. No permission prompts. Coder retained. +- Coder reported: monitoring/listener.py has no `parameter-` literal, so unchanged. Remaining literals after the change: blueprints.py:84-87 (definitions), log.py:157-158 (comment + log-parsing regex, not a named module, wire value unchanged), testing/dummy_instruments/generic.py:454 (test-helper default). Orchestrator confirmed with `git grep -n '"parameter-' -- src/`. +- Orchestrator tests: `uv run pytest -q` -> 174 passed, 4 warnings in 69.09s. +- Six reviewers dispatched for round 0 (target 884558a..b3e6586). +- Permission: reviewer-deepseek asked uv run pytest prefixed with a harmless sw_vers call (read-only). Allowed once. +- Permission: plan-checker-qwen asked git rev-list/diff --stat/status (read-only). Allowed once. +- Permission: plan-checker-qwen asked rg + python AST name-collision check on blueprints.py (read-only). Allowed once. +- reviewer-qwen worker_done (succeeded, approve, 1 nit: log.py regex could use the constant). Retained. +- Permission: plan-checker-qwen re-ran the AST check under uv run (read-only). Allowed once. +- test-reviewer-qwen worker_done (succeeded, changes-needed: 1 should-fix, no test pins the six constant values). Retained. +- plan-checker-qwen worker_done (succeeded, approve, 1 nit). Retained. +- Permission: reviewer-deepseek asked access to /tmp (outside the repo). REJECTED; told it its only output is its report file. +- Permission: reviewer-deepseek asked sleep 120 + ps check on its background pytest run (read-only). Allowed once. +- Permission: reviewer-deepseek asked rm of its own scratch log orchestration/0.5/round-0/_pytest_deepseek.log. Allowed once (its own scratch file). +- test-reviewer-deepseek stalled on a provider 'Upstream error'; plan-checker-deepseek degenerated into garbled output. No reports. Both nudged to resume and report. +- Permission: reviewer-deepseek asked a garbled request. REJECTED; told it to write its report and send worker_done. +- reviewer-deepseek worker_done after nudges (succeeded, approve, 1 nit). Retained. +- Permission: test-reviewer-deepseek asked a garbled request. REJECTED; told it to write its report with the file-write tool only. +- test-reviewer-deepseek wrote its report but stopped before worker_done; plan-checker-deepseek hit another provider error. Both nudged again. +- test-reviewer-deepseek worker_done after nudges (succeeded, approve, 0 findings; argues existing tests already guard the wire strings). Retained. +- plan-checker-deepseek: provider error on its Write call after deciding approve. Nudged to retry. +- plan-checker-deepseek worker_done after nudges (succeeded, approve, 2 nits). Retained. All six round-0 reports present. + +## Round 0 merge (six reports: 5 approve, test-reviewer-qwen changes-needed) + +- test-reviewer-qwen F1 (should-fix): no test pins the wire values of 5 of the 6 new constants. test-reviewer-deepseek disagrees (approve, "existing round-trip tests guard the wire strings"), but its examples (test_base.py, test_broadcaster.py:223) compare literals to literals or to the dummy helper's literal default, never to the constants; orchestrator checked with `git grep` over test/: only test_param_manager.py:117 pins a constant-driven emission. Contradiction resolved in favour of test-reviewer-qwen: the fact is confirmed and the fix is a six-line unit test. KEPT. +- reviewer-qwen F1, plan-checker-deepseek F1, plan-checker-qwen F1 (part): log.py:158 regex keeps the literal; log.py is not a named module. Not sent: nit. +- plan-checker-deepseek F2, plan-checker-qwen F1 (part): testing/dummy_instruments/generic.py:454 default arg keeps the literal; outside the named modules. Not sent: nit. +- reviewer-deepseek F1 (nit): comment says PM_* are "emitted by" Broadcaster instruments before any code does so. Not sent: nit; forward statement matches D10/D26. +- Fix list: 1 item -> fix round 1. +- Fix round 1 dispatched to the coder in its same terminal (task_230ac9f3fd20 / ctx_0d1ace03d034). +- Coder worker_done for fix round 1 (succeeded). Commit eec0c25 "0.5: fix from review round 1: pin the broadcast action constants' wire values in a unit test"; only test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files. Coder retained. +- Orchestrator tests after fix 1: test_broadcaster.py -> 13 passed in 7.43s; full suite -> 175 passed, 4 warnings in 68.94s. +- Re-review 1 dispatched to all six reviewers in their same terminals (target eec0c25): + +| agent id | round | dispatch (task) | +|---|---|---| +| plan-checker-qwen | re-review 1 | ctx_838d7323415f (task_111e6080e396) | +| plan-checker-deepseek | re-review 1 | ctx_2b5d1afc5765 (task_2b936cdef701) | +| test-reviewer-qwen | re-review 1 | ctx_1bd966a6e70d (task_c187152cfe92) | +| test-reviewer-deepseek | re-review 1 | ctx_7aa12e05533b (task_de50b48c4eff) | +| reviewer-qwen | re-review 1 | ctx_a087f9a1d688 (task_a64018eb14f2) | +| reviewer-deepseek | re-review 1 | ctx_bfd917db4c5a (task_65754717fb1e) | + +- Re-review 1: test-reviewer-qwen approve (F1 fixed, 0 new). Retained. +- Re-review 1: reviewer-qwen approve (1 new nit: docstring overstates); plan-checker-qwen approve (0 new). Both retained. +- Permission: plan-checker-deepseek asked access to a garbled path outside the repo. REJECTED; redirected to the relative report path. +- Re-review 1: reviewer-deepseek approve (0 new). Retained. +- Re-review 1: test-reviewer-deepseek approve (0 new; report at the correct path although its worker_done payload string was garbled). Retained. +- Re-review 1: plan-checker-deepseek approve (0 new). Retained. All six round-1 reports present. + +## Round 1 merge (six re-reviews, all `approve`, 0 must-fix / 0 should-fix) + +- Fix-list item 1 (constants pinning test): fixed by eec0c25; confirmed by test-reviewer-qwen and test-reviewer-deepseek (the latter now agrees the orchestrator's adjudication was sound). +- reviewer-qwen new nit: the new test's docstring overstates that the whole suite would pass on constant drift (test_param_manager.py:117 already pins parameter-creation). Not sent: nit. +- All round-0 nits acknowledged as dropped by their reviewers. +- Fix list: EMPTY. Task goes to finish. + +## Finish +- All seven workers released (Orca: state retained, processAction none) and their terminals closed. `worker-list --terminal-state reclaimable` for run_e6f4c00ea2df: 0 rows. +- Checkbox 0.5 set to [x]. + +**Summary.** Outcome: done. Commits: `b3e6586 0.5: broadcast action constants in blueprints.py, used at every literal site in server, gui and client application`, `eec0c25 0.5: fix from review round 1: pin the broadcast action constants' wire values in a unit test`. Fix rounds used: 1. Tests (orchestrator run after fix 1): `uv run pytest -q test/pytest/test_broadcaster.py` -> 13 passed in 7.43s; `uv run pytest -q` -> 175 passed, 4 warnings in 68.94s. diff --git a/orchestration/0.5/round-0/fix-list.md b/orchestration/0.5/round-0/fix-list.md new file mode 100644 index 0000000..f568572 --- /dev/null +++ b/orchestration/0.5/round-0/fix-list.md @@ -0,0 +1,6 @@ +# 0.5 — fix list from round 0 + +1. **Add a unit test pinning the six action constants' wire values.** (test-reviewer-qwen F1, should-fix; contested by test-reviewer-deepseek, orchestrator kept it: see decisions.md.) + - Where: a small test (no server) in `test/pytest/test_broadcaster.py` (or `test/pytest/test_base.py`; pick the file whose subject fits best), importing `PARAMETER_UPDATE`, `PARAMETER_CALL`, `PARAMETER_CREATION`, `PARAMETER_DELETION`, `PM_LOCK_UPDATE`, `PM_TYPE_UPDATE` from `instrumentserver.blueprints`. + - Why: the task says "No behaviour change", i.e. the wire strings stay what they are. Every in-repo emitter and consumer now shares the constants, so a typo in a constant value would pass the whole suite while breaking external subscribers (ADR-0003: "Existing subscribers parse them unchanged"). Today only `PARAMETER_CREATION` is pinned by a test that compares a server emission to the literal (test_param_manager.py:117); test_broadcaster.py:223 asserts "parameter-update" but that emission comes from the dummy helper's literal default, not the constant; no test names "parameter-call" or "parameter-deletion"; the PM_* strings appear in no test. + - What: assert each constant equals exactly `"parameter-update"`, `"parameter-call"`, `"parameter-creation"`, `"parameter-deletion"`, `"pm-lock-update"`, `"pm-type-update"`. Test-only change; no src/ change. diff --git a/orchestration/0.5/round-0/plan-checker-deepseek.md b/orchestration/0.5/round-0/plan-checker-deepseek.md new file mode 100644 index 0000000..3cee425 --- /dev/null +++ b/orchestration/0.5/round-0/plan-checker-deepseek.md @@ -0,0 +1,38 @@ +# 0.5 — plan-checker-deepseek — round 0 + +Verdict: approve + +## Findings + +No must-fix or should-fix findings. + +### F1 — nit (not sent to the coder) +- Where: src/instrumentserver/log.py:158 +- What: the log-parsing regex keeps the literal "parameter-update". +- Why: the task names four modules to edit (server/core.py, gui/instruments.py, + client/application.py, monitoring/listener.py); log.py is not one of them, and the + literal is a parsing pattern, not an emitted action. Leaving it is scope-consistent. +- Suggested fix: none required. + +### F2 — nit (not sent to the coder) +- Where: src/instrumentserver/testing/dummy_instruments/generic.py:454 +- What: the dummy instrument's emit_broadcast default keeps action="parameter-update". +- Why: it is a test-helper default, not an emission site in the four named modules. + Using the constant would be cleaner but touching it would widen scope against + "Do not widen scope" (plan way-of-working rule 6). +- Suggested fix: none required. + +## Notes + +- All six constants are defined in blueprints.py with values "parameter-update", + "parameter-call", "parameter-creation", "parameter-deletion", "pm-lock-update", + "pm-type-update". These match the literals they replaced and the D26 action names. +- Replacement sites: server/core.py (PARAMETER_UPDATE, PARAMETER_CALL, + PARAMETER_CREATION, PARAMETER_DELETION), gui/instruments.py (PARAMETER_CREATION, + PARAMETER_DELETION, PARAMETER_UPDATE, PARAMETER_CALL), client/application.py + (PARAMETER_UPDATE). Wire strings unchanged, so no behaviour change. +- monitoring/listener.py contained no "parameter-" literal, so needed no edit, + consistent with the coder's and orchestrator's git grep. +- Whole suite was not re-run by me; the orchestrator confirmed no literal remains + in the four named modules via git grep, and the changes are string constants + with identical values, so behaviour is preserved. \ No newline at end of file diff --git a/orchestration/0.5/round-0/plan-checker-qwen.md b/orchestration/0.5/round-0/plan-checker-qwen.md new file mode 100644 index 0000000..731a311 --- /dev/null +++ b/orchestration/0.5/round-0/plan-checker-qwen.md @@ -0,0 +1,35 @@ +# 0.5 — plan-checker-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: src/instrumentserver/testing/dummy_instruments/generic.py:454, src/instrumentserver/log.py:157-158 +- What: two `parameter-` literals remain under src/, both left untouched on purpose by the coder. +- Why: task text says "use them in `server/core.py`, `gui/instruments.py`, `client/application.py`, + `monitoring/listener.py` wherever the literals appear" and these files are not on that list; + Way-of-working rule 1 is "Do not widen scope". The coder's call is consistent with the task text. + generic.py:454 is a default argument that sets an action string, so `PARAMETER_UPDATE` there + would be a one-line consistency win; the log.py regex parses log text, not the wire string. +- Suggested fix: none for 0.5; optionally adopt the constants in a later task that touches those files. + +## Notes +- Single commit b3e6586 in the range; message starts with "0.5:" per session protocol step 6. +- All six constants defined in blueprints.py; values match pre-commit literals exactly: + "parameter-update", "parameter-call", "parameter-creation", "parameter-deletion", + plus "pm-lock-update" / "pm-type-update" per D26 ("actions `pm-lock-update`, `pm-type-update`") + and D10 ("Action strings stay strings; module-level constants for all actions"). +- No behaviour change: every substitution is byte-identical (diff read line by line); + `ParameterBroadcastBluePrint` and the wire format are untouched; no name collisions in blueprints.py. +- monitoring/listener.py verified clean: no `parameter-` literal at all; its listeners filter on + `message.name` only, never on action, so the coder's report is correct. +- No `parameter-` occurrence left in any of the four named files (rg across all four: zero matches). +- PM_LOCK_UPDATE / PM_TYPE_UPDATE are defined but unused for now, as expected: their emitters + arrive in tasks 1.3 and 2.5. +- Test files keep literals (test_base.py, test_broadcaster.py, test_param_manager.py); the task + asks only for whole-suite green and those literal asserts pin the wire strings. +- Whole suite: `uv run pytest` -> `174 passed, 4 warnings in 68.60s`; warnings are pre-existing + `PytestUnknownMarkWarning` in test_apps.py, unrelated to this commit. +- Working tree has uncommitted orchestrator files (PLAN_parameter_manager_redesign.md, + orchestration/RUNS.md, orchestration/0.5/); the commit itself touches only the four source files. diff --git a/orchestration/0.5/round-0/reviewer-deepseek.md b/orchestration/0.5/round-0/reviewer-deepseek.md new file mode 100644 index 0000000..7e4276d --- /dev/null +++ b/orchestration/0.5/round-0/reviewer-deepseek.md @@ -0,0 +1,42 @@ +# 0.5 — reviewer-deepseek — round 0 + +Verdict: approve + +## Findings + +No must-fix or should-fix findings. + +### F1 — nit +- Where: src/instrumentserver/blueprints.py:80-89 +- What: The comment block says "Action strings carried in + ParameterBroadcastBluePrint.action" but PM_LOCK_UPDATE / PM_TYPE_UPDATE + are not yet emitted by any code (used from Phase 1.3 / 2.5 onward). +- Why: PM_LOCK_UPDATE and PM_TYPE_UPDATE are defined but currently unused, so + flake/type checks may flag them; also the comment's claim they are "emitted + by instruments implementing the Broadcaster contract" is a forward + statement, not true on this branch yet. +- Suggested fix: Accept as-is; they are required by the task and the plan + (D10, D26) and will be consumed in later phases. + +## Notes + +Wire-string verification: every constant equals the exact literal it replaces +(parameter-update / parameter-call / parameter-creation / parameter-deletion, +plus pm-lock-update and pm-type-update from D10/D26). All replacements in +server/core.py, gui/instruments.py and client/application.py are 1:1 +literal-to-constant swaps inside existing branches; no logic or ordering +changed. No behaviour change. + +grep '"parameter-' across src/ finds literals only in blueprints.py (the new +definitions) plus two sites the coder intentionally left untouched: +log.py:157-158 (a comment and a log-parsing regex `parameter-update:`) and +testing/dummy_instruments/generic.py:454 (a test-helper default argument +`action="parameter-update"`). The task names exactly four modules (server/core, +gui/instruments, client/application, monitoring/listener); log.py and +testing/ are not among them, and the plan's way-of-working rule 6 ("Do not +widen scope") supports leaving them alone. The judgement is correct. +monitoring/listener.py has no literal at all, so nothing to change there. + +Tests: `uv run pytest -q` -> 174 passed, 4 warnings in 68.44s. The 4 warnings +are pre-existing PytestUnknownMarkWarning for @pytest.mark.integration in +test_apps.py, unrelated to this commit. \ No newline at end of file diff --git a/orchestration/0.5/round-0/reviewer-qwen.md b/orchestration/0.5/round-0/reviewer-qwen.md new file mode 100644 index 0000000..0b2e077 --- /dev/null +++ b/orchestration/0.5/round-0/reviewer-qwen.md @@ -0,0 +1,30 @@ +# 0.5 — reviewer-qwen — round 0 + +Verdict: approve + +## Findings + +### F1 — nit +- Where: src/instrumentserver/log.py:158 +- What: The log-parsing regex still hardcodes the wire string ("parameter-update") instead of interpolating PARAMETER_UPDATE. +- Why: It is coupled to the client's log format; if the action string ever changes this regex would silently stop matching. The coder left it on purpose: log.py is not one of the task's four named files, and it parses log text rather than emitting or comparing the action, so this is a preference, not a task violation. +- Suggested fix: None required. Optionally `re.compile(rf"{PARAMETER_UPDATE}:\s*...")`. + +## Notes +- Verified no behaviour change: the four constants equal the former literals exactly; + `pm-lock-update` / `pm-type-update` had no literal anywhere in the parent commit + (`git grep` on 884558a finds none), so the two new constants are purely additive + and match D26. +- `git grep -n "parameter-" b3e6586` over the four named files returns nothing: + server/core.py, gui/instruments.py, client/application.py are fully converted. + monitoring/listener.py genuinely contains no action literal (its listeners filter + on `message.name`, not `message.action`); the coder's report is accurate. +- Two remaining literals judged correctly left: log.py:157-158 (comment + regex, + see F1) and testing/dummy_instruments/generic.py:454 (default argument of the + DummyBroadcasterInstrument test helper; not a named file, equivalent either way). +- Imports are alphabetical and consistent with existing import blocks; no new + circular dependency (blueprints only imports helpers). +- Remaining `.action` uses in src/ are log f-strings (base.py:137, server/core.py:612) + and serialization (blueprints.py:378,392); none compare literals, none need change. +- Tests: `uv run pytest` → `174 passed, 4 warnings in 68.92s` (warnings are + pre-existing unknown-mark warnings in test_apps.py, unrelated). diff --git a/orchestration/0.5/round-0/test-reviewer-deepseek.md b/orchestration/0.5/round-0/test-reviewer-deepseek.md new file mode 100644 index 0000000..1461215 --- /dev/null +++ b/orchestration/0.5/round-0/test-reviewer-deepseek.md @@ -0,0 +1,33 @@ +# 0.5 - test-reviewer-deepseek - round 0 + +Verdict: approve + +## Findings + +None. + +The commit is a pure constants refactor with no new behaviour and no named +test beyond "whole suite green". The existing broadcast tests remain +meaningful and do guard the wire strings: + +- test/pytest/test_base.py:33,41,49,101,149 build and decode + ParameterBroadcastBluePrint with action "parameter-update" and assert the + decoded string equals "parameter-update". This round-trip would fail if any + constant value silently changed. It passes. +- test/pytest/test_param_manager.py:117 asserts bp.action == "parameter-creation" + on the server emission that now uses PARAMETER_CREATION. Passes. +- test/pytest/test_broadcaster.py:223 asserts a received action == + "parameter-update". Passes. + +Wire strings verified identical to the originals in 884558a (git show grep): +parameter-update / parameter-call / parameter-creation / parameter-deletion +all match the new constants; PM_LOCK_UPDATE and PM_TYPE_UPDATE are +"pm-lock-update" / "pm-type-update" per D26. monitoring/listener.py has no +`parameter-` literal, matching the coder's report. The two deliberately +untouched literals (log.py:157-158 regex, and the +testing/dummy_instruments/generic.py:454 default arg) are outside the four +named modules and correct to leave. No test weakened, deleted or skipped. + +## Notes + +Tests run: `uv run pytest -q` -> 174 passed, 4 warnings in 68.39s. \ No newline at end of file diff --git a/orchestration/0.5/round-0/test-reviewer-qwen.md b/orchestration/0.5/round-0/test-reviewer-qwen.md new file mode 100644 index 0000000..3ec80d1 --- /dev/null +++ b/orchestration/0.5/round-0/test-reviewer-qwen.md @@ -0,0 +1,48 @@ +# 0.5 — test-reviewer-qwen — round 0 + +Verdict: changes-needed + +## Findings + +### F1 — should-fix — no test pins the wire-string values of the new constants +- Where: src/instrumentserver/blueprints.py:84-89 (constants); suggested home for the missing test: test/pytest/test_broadcaster.py +- What: only 1 of the 6 new action constants is pinned by any test that compares a server emission against a hardcoded wire string. +- Why: the task's core claim is "No behaviour change" — the wire strings stay what they are. + Since every in-repo emitter and consumer now shares the constant, a typo in a constant + value passes the whole suite while breaking external subscribers (ADR-0003: "Existing + subscribers parse them unchanged"). + Pinned today: PARAMETER_CREATION, via test/pytest/test_param_manager.py:117 + (server emits through the constant, test asserts the literal "parameter-creation"). + Not pinned: PARAMETER_UPDATE, PARAMETER_CALL, PARAMETER_DELETION (test_broadcaster.py:223 + asserts the literal "parameter-update" but that emission comes from the dummy helper's + literal default at testing/dummy_instruments/generic.py:454, not from the constant; no + test asserts "parameter-call" or "parameter-deletion" at all), and PM_LOCK_UPDATE / + PM_TYPE_UPDATE (unused in src until Phases 1-3, no test references the strings; their + names are fixed by D26). +- Suggested fix: add a small unit test (no server) that imports the six constants from + instrumentserver.blueprints and asserts each equals exactly "parameter-update", + "parameter-call", "parameter-creation", "parameter-deletion", "pm-lock-update", + "pm-type-update". Expected result: any constant value drift fails the test. + +## Notes +- Named tests for this task: "whole suite green" (task names no specific test file); + ran the full suite. Summary line: `174 passed, 4 warnings in 68.55s (0:01:08)`. + The 4 warnings are pre-existing PytestUnknownMarkWarning for pytest.mark.integration in + test/pytest/test_apps.py; not introduced by this commit. +- The commit adds no new or changed tests and weakens, deletes or skips none. +- No-behaviour-change verified on the wire: each of the four in-use constants equals its + previous literal exactly (parameter-update, parameter-call, parameter-creation, + parameter-deletion); the diff is purely literal-to-constant swaps with no logic edits. + PM_LOCK_UPDATE / PM_TYPE_UPDATE match D26 and are unused for now, as intended. +- monitoring/listener.py: confirmed zero `parameter-` literals and no `action` handling; + the coder's claim holds, so nothing to use there. +- Deliberately untouched literals, judged against the task text: log.py:157-158 is a + comment plus a regex that parses client-station log lines, and log.py is not one of the + four named modules; testing/dummy_instruments/generic.py:454 is a test-helper default + argument, also outside the named-file list. Plan scope rule "Do not widen scope" supports + leaving both. From the test side, keeping generic.py:454 a literal is actually beneficial: + it keeps test_broadcaster.py:223 an independent literal check of that wire string; using + the constant there would make the assertion circular. No finding. +- test/pytest/test_base.py and test_broadcaster.py make_bp() use hardcoded + "parameter-update" literals when building blueprints; that is correct and independent, + not a weakness. diff --git a/orchestration/0.5/round-1/fix-list.md b/orchestration/0.5/round-1/fix-list.md new file mode 100644 index 0000000..c89bb5b --- /dev/null +++ b/orchestration/0.5/round-1/fix-list.md @@ -0,0 +1,3 @@ +# 0.5 — fix list from round 1 + +Empty. All six re-reviews approve with no must-fix or should-fix findings. diff --git a/orchestration/0.5/round-1/plan-checker-deepseek.md b/orchestration/0.5/round-1/plan-checker-deepseek.md new file mode 100644 index 0000000..ff966af --- /dev/null +++ b/orchestration/0.5/round-1/plan-checker-deepseek.md @@ -0,0 +1,28 @@ +# 0.5 — plan-checker-deepseek — round 1 + +Verdict: approve + +## Previous findings + +- F1 (nit) log.py:158 regex keeps the literal: dropped by orchestrator + (decisions.md "Not sent: nit"). +- F2 (nit) dummy_instruments/generic.py:454 default keeps the literal: + dropped by orchestrator (decisions.md "Not sent: nit"). + +## Findings + +No new must-fix or should-fix findings. + +## Notes + +- Fix commit eec0c25 adds test_broadcast_action_constants_pin_the_wire_strings + to test/pytest/test_broadcaster.py, asserting each of the six constants + against its exact wire string. No src/ change. +- It is a no-server unit test, consistent with "Tests per layer" (plan + way-of-working rule 5). +- The pinned values match what the constants held in round 0 and the D26 + action names, so wire strings are still unchanged: no behaviour change. +- Test-only change; nothing in my focus area (scope, vocabulary, wire + strings, plan rules) is broken or weakened. +- Full suite: 175 passed (run by the orchestrator); fix adds one passing + test, so 175 includes it. \ No newline at end of file diff --git a/orchestration/0.5/round-1/plan-checker-qwen.md b/orchestration/0.5/round-1/plan-checker-qwen.md new file mode 100644 index 0000000..daef63d --- /dev/null +++ b/orchestration/0.5/round-1/plan-checker-qwen.md @@ -0,0 +1,31 @@ +# 0.5 — plan-checker-qwen — round 1 + +Verdict: approve + +## Previous findings + +- F1 (nit, log.py:157-158 + generic.py:454 literals left untouched): dropped by orchestrator + (decisions.md "Round 0 merge": both parts "Not sent: nit"). Not sent to the coder, so still + present in the tree, as expected. No action needed. + +## Fix commit check + +- eec0c25 adds test_broadcast_action_constants_pin_the_wire_strings to + test/pytest/test_broadcaster.py; no src/ change. +- The test was requested by the fix list item 1 (test-reviewer-qwen F1, kept by orchestrator); + it asserts all six constants equal exactly "parameter-update", "parameter-call", + "parameter-creation", "parameter-deletion", "pm-lock-update", "pm-type-update" — the wire + values from D26 and ADR-0003 ("Existing subscribers parse them unchanged"). +- It can fail: literal-equality asserts, not a tautology. +- No behaviour change: test-only commit; wire strings and API untouched. +- Plan rules: commit message starts with "0.5:", separate fix-round commit, glossary terms + used ("Broadcast", "subscribers") in docstring and section comment. +- Placement: test_broadcaster.py, which the fix list named as an acceptable file. + +## New findings + +- None. + +## Notes +- `uv run pytest test/pytest/test_broadcaster.py` -> 13 passed in 7.47s (12 before the fix). +- Orchestrator ran the full suite after the fix: 175 passed (decisions.md). diff --git a/orchestration/0.5/round-1/reviewer-deepseek.md b/orchestration/0.5/round-1/reviewer-deepseek.md new file mode 100644 index 0000000..374a5ce --- /dev/null +++ b/orchestration/0.5/round-1/reviewer-deepseek.md @@ -0,0 +1,31 @@ +# 0.5 — reviewer-deepseek — round 1 + +Verdict: approve + +## Previous findings + +- F1 (nit, blueprints.py:80-89 comment is a forward statement about the PM_* + constants): dropped by the orchestrator (decisions.md "Round 0 merge": "Not + sent: nit; forward statement matches D10/D26"). No fix required. + +## Findings + +No must-fix or should-fix findings in this round. + +## Notes + +Fix commit eec0c25 adds only a no-server unit test +`test_broadcast_action_constants_pin_the_wire_strings` to +test/pytest/test_broadcaster.py; no src/ change (diff confirmations: only +that file changed, and the b3e6586 src/ edits are untouched). + +The new test imports all six constants from `instrumentserver.blueprints` +and asserts each equals its exact wire string. Values match the pre-fix +literals and D10/D26 (pm-lock-update, pm-type-update). Correct values, +correct import path, sensible placement in the Broadcaster contract test +file, no fixtures/server needed. It strengthens rather than weakens my +focus area: a typo in any constant would now fail this test, closing the +gap that the fix list described. + +Test run: `uv run pytest -q test/pytest/test_broadcaster.py` -> 13 passed in +7.45s. No new findings caused by the fix. \ No newline at end of file diff --git a/orchestration/0.5/round-1/reviewer-qwen.md b/orchestration/0.5/round-1/reviewer-qwen.md new file mode 100644 index 0000000..03c0036 --- /dev/null +++ b/orchestration/0.5/round-1/reviewer-qwen.md @@ -0,0 +1,26 @@ +# 0.5 — reviewer-qwen — round 1 + +Verdict: approve + +## Previous findings +- F1 (nit, log.py:158 regex hardcodes the wire string): dropped by the orchestrator + (decisions.md "Round 0 merge": "Not sent: nit"). Not fixed, as intended. + +## New findings + +### F2 — nit +- Where: test/pytest/test_broadcaster.py:139-141 (new test docstring) +- What: The docstring claims "the whole suite would pass even if a constant's value + drifted", but test_param_manager.py:117 already compares a server-driven emission + to the literal "parameter-creation", so that one constant is pinned by an existing test. +- Why: The claim slightly overstates; five of six constants are unpinned, not all six. +- Suggested fix: Reword, e.g. "most constants' values would drift undetected". + +## Notes +- Fix commit eec0c25 touches only test/pytest/test_broadcaster.py (no src/ change), + so nothing in my focus area broke or weakened; no behaviour change. +- The new test imports all six constants, asserts each exact wire value, needs no + server fixture, and can genuinely fail; it directly implements fix-list item 1. +- Imports alphabetical and consistent with the file's existing import blocks. +- Tests: `uv run pytest test/pytest/test_broadcaster.py` -> 13 passed in 7.47s. + Orchestrator ran the full suite after the fix: 175 passed, 4 warnings. diff --git a/orchestration/0.5/round-1/test-reviewer-deepseek.md b/orchestration/0.5/round-1/test-reviewer-deepseek.md new file mode 100644 index 0000000..4127a7c --- /dev/null +++ b/orchestration/0.5/round-1/test-reviewer-deepseek.md @@ -0,0 +1,38 @@ +# 0.5 — test-reviewer-deepseek — round 1 + +Verdict: approve + +## Findings + +None. + +## Previous findings + +My round-0 report was approve with zero formal findings (no must-fix, +should-fix, or nit sent to the coder). The single fix-list item — add a +no-server unit test pinning the six constant wire values — came from +test-reviewer-qwen (should-fix), not from me. I had contested it in round 0 +("existing round-trip tests already guard the wire strings"); the orchestrator +checked the facts (only test_param_manager.py:117 pins a constant-driven +emission; my cited examples compare literals to literals or to the dummy +helper's literal default) and kept it. That adjudication is sound, so there is +nothing for me to mark fixed/not-fixed/dropped beyond noting I had no findings +of my own in round 0. + +## Fix commit eec0c25 (test/pytest/test_broadcaster.py only) + +The added test `test_broadcast_action_constants_pin_the_wire_strings` asserts +each of the six constants equals exactly its wire string: +parameter-update / parameter-call / parameter-creation / parameter-deletion / +pm-lock-update / pm-type-update. It is a no-server unit test at the right +layer, correctly named in the plan's vocabulary (D26 names), and it would fail +if any constant value drifted. This closes the gap that justified fix round 1. +No src/ change. + +Nothing existing was weakened, deleted or skipped; the file grew from 12 to 13 +tests and all prior tests are unchanged. + +## Notes + +Tests run: `uv run pytest test/pytest/test_broadcaster.py -q` -> 13 passed in +7.47s. Orchestrator reports full suite 175 passed. \ No newline at end of file diff --git a/orchestration/0.5/round-1/test-reviewer-qwen.md b/orchestration/0.5/round-1/test-reviewer-qwen.md new file mode 100644 index 0000000..b276975 --- /dev/null +++ b/orchestration/0.5/round-1/test-reviewer-qwen.md @@ -0,0 +1,37 @@ +# 0.5 — test-reviewer-qwen — round 1 + +Verdict: approve + +## Previous findings + +- F1 (should-fix, no test pins the wire values of the new constants): FIXED. + The fix commit eec0c25 adds test_broadcast_action_constants_pin_the_wire_strings + to test/pytest/test_broadcaster.py (the file I suggested). It is a no-server unit + test that asserts each of the six constants equals exactly "parameter-update", + "parameter-call", "parameter-creation", "parameter-deletion", "pm-lock-update", + "pm-type-update". Any value drift now fails the suite; the literals are independent + of the constants (not circular). No src/ change in the fix commit. +- No other findings of mine from round 0. The log.py / generic.py omissions were + notes in my report, not findings; decisions.md confirms they were dropped as + nits by other reviewers, which matches my round-0 position (out of the task's + named files, and the generic.py literal keeps test_broadcaster.py:223 an + independent check). + +## Did the fix weaken or break anything + +- No. The commit is purely additive: one import block and one new test function + in test/pytest/test_broadcaster.py. No existing test modified, no src/ touched, + no test skipped or deleted. + +## New findings + +- None. The new test is at the right layer (unit, no server), named in the plan's + vocabulary (Broadcast, action, wire strings), and would fail if the feature were + broken (a drifted constant value). + +## Notes +- `uv run pytest test/pytest/test_broadcaster.py` -> 13 passed in 7.46s + (12 previous + 1 new). +- Orchestrator ran the full suite after the fix: 175 passed, 4 warnings + (pre-existing PytestUnknownMarkWarning in test_apps.py). +- Counts: 0 must-fix, 0 should-fix, 0 nit. From cb49c53fcb5e7e0d083da9eeb6f3dad204967904 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 00:19:58 -0500 Subject: [PATCH 019/107] Orchestration: run report for 0.3..0.5 --- orchestration/RUNS.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/orchestration/RUNS.md b/orchestration/RUNS.md index a08a564..1026076 100644 --- a/orchestration/RUNS.md +++ b/orchestration/RUNS.md @@ -38,3 +38,29 @@ - Workers still alive: none. - Permission prompts: ~45 handled; all read-only or coder-allowed edits/commits allowed once, 3 rejected (reviewer-deepseek asked for ~/.agents/roles and a garbled path outside the repo; plan-checker-deepseek tried to write its report via a python heredoc whose target was not visible). - Process notes: (1) deepseek reviewers stalled three times (one garbled-output degeneration, one provider "Upstream error", one idle after concluding); a terminal nudge recovered each. (2) The 0.0 fixture removed the port collisions seen in the pilot run; reviewers ran the suite in parallel with no spurious failures. (3) Nits not sent but worth folding into a later task touching conftest.py: the server_port docstring's "outside the OS ephemeral range" claim is false on Linux. + +## Run 2026-09-23 — run_e6f4c00ea2df + +- Plan: PLAN_parameter_manager_redesign.md +- Tasks: 0.3, 0.4, 0.5 (--from 0.3; rest of Phase 0) +- Branch: marcosfrenkel/new-param-manager +- Starting commit: 0fbbddf9ba0410fbbd429f5f348067726591d4d8 + +### Report + +| Task | Outcome | Commits | Fix rounds | Final tests | +|---|---|---|---|---| +| 0.3 | done | `04c4cbc 0.3: server registers itself as a broadcast sink on Broadcaster instruments`, `5167241 0.3: fix from review round 1: test the config-load sink registration path` | 1 | named file 12 passed; full suite 173 passed, 4 warnings | +| 0.4 | done | `56ece34 0.4: fix latent KeyError in parameter-creation broadcast and pass broadcast port to the parameter manager GUI launcher` | 0 | named files 31 passed; full suite 174 passed, 4 warnings | +| 0.5 | done | `b3e6586 0.5: broadcast action constants in blueprints.py, used at every literal site in server, gui and client application`, `eec0c25 0.5: fix from review round 1: pin the broadcast action constants' wire values in a unit test` | 1 | named file 13 passed; full suite 175 passed, 4 warnings | + +- Phase 0 is complete. Stopped at the end of the phase (rule 7). Next open task: 1.1 `ManagedParameter`. +- Open questions / notes for the user (none block Phase 1): + 1. `_runInitScript` (server/core.py, run from `startServer` after `__init__`) can add instruments to the Station after the `__init__` sink-registration loop; those instruments would get no Broadcaster sink. The plan lists only two entry points, so 0.3 followed the plan. Decide whether the init-script path needs registration (small follow-up task) or is accepted. (plan-checker-qwen, reviewer-qwen, 0.3) + 2. Once the Server registers as a sink, the mixin's public `broadcast` / `add_broadcast_sink` / `remove_broadcast_sink` are wire-callable on any Broadcaster proxy. Inert in practice (callables do not survive JSON; a malformed remote `broadcast(dict)` hits the logged sink-error path), but a client can inject a Broadcast. A 0.2 design consequence; accept or add a note. (reviewer-qwen, 0.2 and 0.3) + 3. 0.4: the coder added `type=int` to the launcher's `--port` argparse argument so the plan's `args.port + 1` works; all six reviewers judged it in scope. Recorded here because it goes slightly beyond D24's literal text. + 4. Carried over from the previous run: the plan's Testing paragraph and the `test_gui_navigation.py` fact line still mention fixed ports, made stale by D27 / task 0.0. +- Nits not sent, worth folding into a later task: `capture_broadcasts`/`wait_for_broadcasts` are now duplicated in test_broadcaster.py and test_param_manager.py (consolidate into conftest.py when 1.3 / 2.5 need a third copy); log.py:158 regex and testing/dummy_instruments/generic.py:454 keep literal action strings. +- Workers still alive: none. +- Permission prompts: ~20 handled; all read-only or reviewers' own files allowed once; 6 rejected (garbled paths or commands from deepseek reviewers, one /tmp access). +- Process notes: (1) deepseek reviewers stalled 11 times across the three tasks (provider "Upstream error" or garbled-output degeneration), each recovered by a terminal nudge; consider a different model for the deepseek slots. (2) Orchestrator shell pitfalls found and fixed mid-run: `orca` reads stdin inside `while read` loops (use ` Date: Thu, 24 Sep 2026 10:21:02 -0500 Subject: [PATCH 020/107] Orchestration: historian role, history for Phase 0, stop tracking raw reports - New historian role (.agents/roles/historian.md, bin/historian-claude.sh): a Claude Opus session that turns each task's reviews and decision log into one section of HISTORY_.md. Skill Step 7 runs it at the end of every task; the orchestrator's end-of-task commit is now 'T: history'. - HISTORY_parameter_manager_redesign.md: backfilled sections for 0.1, 0.0, 0.2, 0.3, 0.4, 0.5 (in the order they were done). - orchestration/ is git-ignored and untracked; the raw files stay on disk. Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/roles/ROSTER.md | 5 + .agents/roles/bin/historian-claude.sh | 23 +++ .agents/roles/historian.md | 64 +++++++++ .agents/skills/orchestrate-plan/SKILL.md | 41 ++++-- .../orchestrate-plan/references/task-specs.md | 20 +++ .gitignore | 3 + HISTORY_parameter_manager_redesign.md | 135 ++++++++++++++++++ PLAN_parameter_manager_redesign.md | 7 +- orchestration/0.0/decisions.md | 93 ------------ orchestration/0.0/round-0/fix-list.md | 3 - .../0.0/round-0/plan-checker-deepseek.md | 23 --- .../0.0/round-0/plan-checker-qwen.md | 28 ---- .../0.0/round-0/reviewer-deepseek.md | 26 ---- orchestration/0.0/round-0/reviewer-qwen.md | 23 --- .../0.0/round-0/test-reviewer-deepseek.md | 60 -------- .../0.0/round-0/test-reviewer-qwen.md | 26 ---- orchestration/0.1/decisions.md | 56 -------- orchestration/0.1/round-0/fix-list.md | 7 - .../0.1/round-0/plan-checker-deepseek.md | 38 ----- .../0.1/round-0/plan-checker-qwen.md | 22 --- .../0.1/round-0/reviewer-deepseek.md | 20 --- orchestration/0.1/round-0/reviewer-qwen.md | 26 ---- .../0.1/round-0/test-reviewer-deepseek.md | 25 ---- .../0.1/round-0/test-reviewer-qwen.md | 20 --- orchestration/0.2/decisions.md | 96 ------------- orchestration/0.2/round-0/fix-list.md | 3 - .../0.2/round-0/plan-checker-deepseek.md | 76 ---------- .../0.2/round-0/plan-checker-qwen.md | 60 -------- .../0.2/round-0/reviewer-deepseek.md | 53 ------- orchestration/0.2/round-0/reviewer-qwen.md | 79 ---------- .../0.2/round-0/test-reviewer-deepseek.md | 45 ------ .../0.2/round-0/test-reviewer-qwen.md | 20 --- orchestration/0.2/round-1/fix-list.md | 3 - .../0.2/round-1/plan-checker-deepseek.md | 28 ---- .../0.2/round-1/plan-checker-qwen.md | 43 ------ .../0.2/round-1/reviewer-deepseek.md | 42 ------ orchestration/0.2/round-1/reviewer-qwen.md | 41 ------ .../0.2/round-1/test-reviewer-deepseek.md | 37 ----- .../0.2/round-1/test-reviewer-qwen.md | 18 --- orchestration/0.3/decisions.md | 88 ------------ orchestration/0.3/round-0/fix-list.md | 6 - .../0.3/round-0/plan-checker-deepseek.md | 26 ---- .../0.3/round-0/plan-checker-qwen.md | 26 ---- .../0.3/round-0/reviewer-deepseek.md | 34 ----- orchestration/0.3/round-0/reviewer-qwen.md | 67 --------- .../0.3/round-0/test-reviewer-deepseek.md | 17 --- .../0.3/round-0/test-reviewer-qwen.md | 34 ----- orchestration/0.3/round-1/fix-list.md | 3 - .../0.3/round-1/plan-checker-deepseek.md | 30 ---- .../0.3/round-1/plan-checker-qwen.md | 34 ----- .../0.3/round-1/reviewer-deepseek.md | 31 ---- orchestration/0.3/round-1/reviewer-qwen.md | 68 --------- .../0.3/round-1/test-reviewer-deepseek.md | 33 ----- .../0.3/round-1/test-reviewer-qwen.md | 24 ---- orchestration/0.4/decisions.md | 49 ------- orchestration/0.4/round-0/fix-list.md | 3 - .../0.4/round-0/plan-checker-deepseek.md | 10 -- .../0.4/round-0/plan-checker-qwen.md | 80 ----------- .../0.4/round-0/reviewer-deepseek.md | 21 --- orchestration/0.4/round-0/reviewer-qwen.md | 23 --- .../0.4/round-0/test-reviewer-deepseek.md | 17 --- .../0.4/round-0/test-reviewer-qwen.md | 28 ---- orchestration/0.5/decisions.md | 83 ----------- orchestration/0.5/round-0/fix-list.md | 6 - .../0.5/round-0/plan-checker-deepseek.md | 38 ----- .../0.5/round-0/plan-checker-qwen.md | 35 ----- .../0.5/round-0/reviewer-deepseek.md | 42 ------ orchestration/0.5/round-0/reviewer-qwen.md | 30 ---- .../0.5/round-0/test-reviewer-deepseek.md | 33 ----- .../0.5/round-0/test-reviewer-qwen.md | 48 ------- orchestration/0.5/round-1/fix-list.md | 3 - .../0.5/round-1/plan-checker-deepseek.md | 28 ---- .../0.5/round-1/plan-checker-qwen.md | 31 ---- .../0.5/round-1/reviewer-deepseek.md | 31 ---- orchestration/0.5/round-1/reviewer-qwen.md | 26 ---- .../0.5/round-1/test-reviewer-deepseek.md | 38 ----- .../0.5/round-1/test-reviewer-qwen.md | 37 ----- orchestration/RUNS.md | 66 --------- 78 files changed, 282 insertions(+), 2483 deletions(-) create mode 100755 .agents/roles/bin/historian-claude.sh create mode 100644 .agents/roles/historian.md create mode 100644 HISTORY_parameter_manager_redesign.md delete mode 100644 orchestration/0.0/decisions.md delete mode 100644 orchestration/0.0/round-0/fix-list.md delete mode 100644 orchestration/0.0/round-0/plan-checker-deepseek.md delete mode 100644 orchestration/0.0/round-0/plan-checker-qwen.md delete mode 100644 orchestration/0.0/round-0/reviewer-deepseek.md delete mode 100644 orchestration/0.0/round-0/reviewer-qwen.md delete mode 100644 orchestration/0.0/round-0/test-reviewer-deepseek.md delete mode 100644 orchestration/0.0/round-0/test-reviewer-qwen.md delete mode 100644 orchestration/0.1/decisions.md delete mode 100644 orchestration/0.1/round-0/fix-list.md delete mode 100644 orchestration/0.1/round-0/plan-checker-deepseek.md delete mode 100644 orchestration/0.1/round-0/plan-checker-qwen.md delete mode 100644 orchestration/0.1/round-0/reviewer-deepseek.md delete mode 100644 orchestration/0.1/round-0/reviewer-qwen.md delete mode 100644 orchestration/0.1/round-0/test-reviewer-deepseek.md delete mode 100644 orchestration/0.1/round-0/test-reviewer-qwen.md delete mode 100644 orchestration/0.2/decisions.md delete mode 100644 orchestration/0.2/round-0/fix-list.md delete mode 100644 orchestration/0.2/round-0/plan-checker-deepseek.md delete mode 100644 orchestration/0.2/round-0/plan-checker-qwen.md delete mode 100644 orchestration/0.2/round-0/reviewer-deepseek.md delete mode 100644 orchestration/0.2/round-0/reviewer-qwen.md delete mode 100644 orchestration/0.2/round-0/test-reviewer-deepseek.md delete mode 100644 orchestration/0.2/round-0/test-reviewer-qwen.md delete mode 100644 orchestration/0.2/round-1/fix-list.md delete mode 100644 orchestration/0.2/round-1/plan-checker-deepseek.md delete mode 100644 orchestration/0.2/round-1/plan-checker-qwen.md delete mode 100644 orchestration/0.2/round-1/reviewer-deepseek.md delete mode 100644 orchestration/0.2/round-1/reviewer-qwen.md delete mode 100644 orchestration/0.2/round-1/test-reviewer-deepseek.md delete mode 100644 orchestration/0.2/round-1/test-reviewer-qwen.md delete mode 100644 orchestration/0.3/decisions.md delete mode 100644 orchestration/0.3/round-0/fix-list.md delete mode 100644 orchestration/0.3/round-0/plan-checker-deepseek.md delete mode 100644 orchestration/0.3/round-0/plan-checker-qwen.md delete mode 100644 orchestration/0.3/round-0/reviewer-deepseek.md delete mode 100644 orchestration/0.3/round-0/reviewer-qwen.md delete mode 100644 orchestration/0.3/round-0/test-reviewer-deepseek.md delete mode 100644 orchestration/0.3/round-0/test-reviewer-qwen.md delete mode 100644 orchestration/0.3/round-1/fix-list.md delete mode 100644 orchestration/0.3/round-1/plan-checker-deepseek.md delete mode 100644 orchestration/0.3/round-1/plan-checker-qwen.md delete mode 100644 orchestration/0.3/round-1/reviewer-deepseek.md delete mode 100644 orchestration/0.3/round-1/reviewer-qwen.md delete mode 100644 orchestration/0.3/round-1/test-reviewer-deepseek.md delete mode 100644 orchestration/0.3/round-1/test-reviewer-qwen.md delete mode 100644 orchestration/0.4/decisions.md delete mode 100644 orchestration/0.4/round-0/fix-list.md delete mode 100644 orchestration/0.4/round-0/plan-checker-deepseek.md delete mode 100644 orchestration/0.4/round-0/plan-checker-qwen.md delete mode 100644 orchestration/0.4/round-0/reviewer-deepseek.md delete mode 100644 orchestration/0.4/round-0/reviewer-qwen.md delete mode 100644 orchestration/0.4/round-0/test-reviewer-deepseek.md delete mode 100644 orchestration/0.4/round-0/test-reviewer-qwen.md delete mode 100644 orchestration/0.5/decisions.md delete mode 100644 orchestration/0.5/round-0/fix-list.md delete mode 100644 orchestration/0.5/round-0/plan-checker-deepseek.md delete mode 100644 orchestration/0.5/round-0/plan-checker-qwen.md delete mode 100644 orchestration/0.5/round-0/reviewer-deepseek.md delete mode 100644 orchestration/0.5/round-0/reviewer-qwen.md delete mode 100644 orchestration/0.5/round-0/test-reviewer-deepseek.md delete mode 100644 orchestration/0.5/round-0/test-reviewer-qwen.md delete mode 100644 orchestration/0.5/round-1/fix-list.md delete mode 100644 orchestration/0.5/round-1/plan-checker-deepseek.md delete mode 100644 orchestration/0.5/round-1/plan-checker-qwen.md delete mode 100644 orchestration/0.5/round-1/reviewer-deepseek.md delete mode 100644 orchestration/0.5/round-1/reviewer-qwen.md delete mode 100644 orchestration/0.5/round-1/test-reviewer-deepseek.md delete mode 100644 orchestration/0.5/round-1/test-reviewer-qwen.md delete mode 100644 orchestration/RUNS.md diff --git a/.agents/roles/ROSTER.md b/.agents/roles/ROSTER.md index 1abd631..3b8a4ef 100644 --- a/.agents/roles/ROSTER.md +++ b/.agents/roles/ROSTER.md @@ -16,6 +16,7 @@ instructions and work with any coding agent. | `test-reviewer-qwen` | `test-reviewer.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent test-reviewer-qwen` | yes | | `plan-checker-deepseek` | `plan-checker.md` | opencode | lumen/deepseek-v4-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent plan-checker-deepseek` | yes | | `plan-checker-qwen` | `plan-checker.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent plan-checker-qwen` | yes | +| `historian` | `historian.md` | claude | opus | `.agents/roles/bin/historian-claude.sh` | yes | **Last column.** "yes" means the runner loads the role file itself as standing instructions. "no" means the orchestrator must paste the role file's full text at the top of @@ -39,6 +40,10 @@ Orca's preamble tells workers to run. `checkout`, `switch`, `branch -d/-D`, `clean`; `git add -A`, `git add .`, `git add --all`. **Reviewers also:** editing anything outside `orchestration/`, in-place shell edits (`sed -i`, `perl -pi`, `perl -i`), `git add`, `git commit`. +**Historian (Claude Code):** its launcher, `bin/historian-claude.sh`, allows reading, +read-only git, the Orca worker commands and editing `HISTORY_*.md` only; it denies commits, +pushes and deletes. It never needs the opencode rules above. + **Everything else: ask.** The question goes to whoever watches the agent: the orchestrator, which decides per `SKILL.md` "Permission prompts". diff --git a/.agents/roles/bin/historian-claude.sh b/.agents/roles/bin/historian-claude.sh new file mode 100755 index 0000000..2ccfbc5 --- /dev/null +++ b/.agents/roles/bin/historian-claude.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Launch the historian role as a Claude Code session with only the tools it needs: +# read anything, read-only git, Orca worker commands, and edits to HISTORY_*.md only. +# Run from the repository root. Extra arguments are passed to claude. +set -eu +root="$(git rev-parse --show-toplevel)" +cd "$root" +export PYTHONDONTWRITEBYTECODE=1 +exec claude \ + --model "${HISTORIAN_MODEL:-opus}" \ + --append-system-prompt-file .agents/roles/historian.md \ + --permission-mode default \ + --allowedTools \ + "Read" "Grep" "Glob" \ + "Edit(/HISTORY_*.md)" "Write(/HISTORY_*.md)" \ + "Bash(git log:*)" "Bash(git show:*)" "Bash(git diff:*)" "Bash(git status:*)" \ + "Bash(ls:*)" "Bash(cat:*)" "Bash(head:*)" "Bash(tail:*)" "Bash(wc:*)" \ + "Bash(orca orchestration send:*)" "Bash(orca orchestration check:*)" \ + "Bash(orca orchestration ask:*)" \ + --disallowedTools \ + "Bash(git commit:*)" "Bash(git add:*)" "Bash(git push:*)" "Bash(git reset:*)" \ + "Bash(git checkout:*)" "Bash(git stash:*)" "Bash(rm:*)" \ + "$@" diff --git a/.agents/roles/historian.md b/.agents/roles/historian.md new file mode 100644 index 0000000..b47c761 --- /dev/null +++ b/.agents/roles/historian.md @@ -0,0 +1,64 @@ +# Role: historian + +You write the history of one finished plan task. The code is done and approved. Your job is +to read everything the other agents produced for that task and turn it into one clear +section of the plan's history file, so that someone reading the commits later understands +what was built, how it changed along the way, and why. + +You only read, except for one file: the history file your task spec names. You add one +section at the end of it. You never change earlier sections, never edit code, and never commit. + +## What you read + +- The task's text in the plan file. +- Every commit of the task: `git log --oneline ..` and `git show ` for each. +- The task's working folder, `orchestration//`: `decisions.md` (the orchestrator's log), + every `round-*/.md` review, and every `round-*/fix-list.md`. +- The history file itself, so your section matches the earlier ones in tone and format. + +Use your Read, Grep and Glob tools for files, and single `git log` / `git show` commands +for commits. Avoid shell loops, `;` chains and `$(...)`: they need permission and stall you. + +Check claims against the commits. If a report says something was fixed, the fix +commit should show it. When the two disagree, the commits win, and you say so. + +## The section you write + +Add it at the bottom of the history file: + +```markdown +## — + + + +### Commit by commit +- `` . For fix commits: what the reviewers caught that led to it, + and which reviewers (e.g. "both test reviewers"). +- ... + +### Dropped findings +- — . Only findings worth knowing about; skip routine nits. + +### Questions to Marcos +- → . Leave the heading out if there were none. + +### Loose ends +- . Leave the heading out if there were none. + +### Process notes +- . + Do not list routine allowed permissions. Leave the heading out if there were none. +``` + +Write for a reader who knows the project but was not watching. Use the glossary's terms +(the plan names the glossary file). Be specific: a hash, a test name, a file. Keep it short. +Say each thing once, and leave out anything that doesn't help explain how the code got +to where it is. + +## When you finish + +Report back through Orca as your spec's preamble describes, passing the history file as +the report path. If something in the working folder is missing or contradicts the +commits in a way you cannot resolve, say so in your summary instead of guessing. diff --git a/.agents/skills/orchestrate-plan/SKILL.md b/.agents/skills/orchestrate-plan/SKILL.md index 17d9e1c..6745f1a 100644 --- a/.agents/skills/orchestrate-plan/SKILL.md +++ b/.agents/skills/orchestrate-plan/SKILL.md @@ -42,8 +42,8 @@ Roles and the agents that fill them are listed in **`.agents/roles/ROSTER.md`**. startup. It gives, per agent id: its role file, the runner (e.g. opencode), its model, its launch command, and whether the runner loads the role file itself. -The current roster has seven agents: one `coder`, and six **reviewers**, three roles each on -two models: +The current roster has eight agents: one `coder`, one `historian` (writes the task's history +section at the end, Step 7), and six **reviewers**, three roles each on two models: - `reviewer-*`: general code review - `test-reviewer-*`: do the tests prove the task, and what is untested @@ -63,7 +63,8 @@ Reviewers write only their own report file. Only the coder edits code. a time: the coder. Reviewers only read. 3. **Commits.** The coder commits code and tests: one commit for the first implementation, one per fix round, each message starting with the task number (`0.1: ...`). You commit - only `orchestration//` files and the plan's checkboxes. Nobody pushes, amends, + only the history file (`HISTORY_.md`, see Step 7) and the plan's checkboxes. + `orchestration/` is git-ignored working space: never commit it. Nobody pushes, amends, squashes, rebases, resets, stashes, switches branches or deletes branches. Ever. 4. **Fresh sessions per task.** Within a task, reuse the same coder and the same six reviewer sessions across fix rounds. At task end, release all seven. @@ -84,7 +85,7 @@ Reviewers write only their own report file. Only the coder edits code. parameter-manager plan: `CONTEXT.md` and `docs/adr/*`). Find the tasks to run. 5. `orca orchestration run-create --objective ": tasks .." --json`. Keep the Run id. -6. Create `orchestration/` if missing. Append a run header to `orchestration/RUNS.md`: +6. Create `orchestration/` if missing. Append a run header to `orchestration/RUNS.md` (local only, not committed): date, plan, tasks, branch, starting commit (`git rev-parse HEAD`). ## The loop for one task @@ -167,7 +168,7 @@ findings were all dropped with a logged reason) → go to Step 6. previous report path. Report path: `D/round-/.md` for fix round `k`. 6. Wait for all six, retain them, go back to Step 4. -### Step 6: finish the task +### Step 6: close the task's workers 1. Release all seven workers: `orca orchestration worker-release --dispatch --json` for each final Dispatch. Because you created their terminals yourself, Orca answers @@ -175,12 +176,25 @@ findings were all dropped with a logged reason) → go to Step 6. close each one: `orca terminal close --terminal --json`. Then confirm `orca orchestration worker-list --run --terminal-state reclaimable --json` shows none of this task's workers. -2. Change the checkbox to `[x]`. Add a one-line summary to `decisions.md`: commits - (`git log --oneline $BASE..HEAD`), fix rounds used, test summary line. -3. Commit your paper trail: - `git add orchestration/T && git commit -m "T: orchestration record"`. - Only those paths. Never `git add -A`. -4. Next task. If `--only` was given, or the next task is in a new phase, stop and report. +2. Add a one-line summary to `decisions.md`: commits (`git log --oneline $BASE..HEAD`), + fix rounds used, test summary line. + +### Step 7: history + +The history file is named after the plan: `PLAN_.md` → `HISTORY_.md`, in the +same folder. Create it with a one-line title (`# History: `) if it does not exist. + +1. Launch the `historian` (see `ROSTER.md`; it is a fresh session every task) with the + "Historian: write section" spec from `references/task-specs.md`. +2. Wait for its `worker_done`. Check: `git status --porcelain` shows only the history file + changed (plus the ignored `orchestration/`); the new section is at the end; no earlier + section changed (`git diff` of the history file only adds lines). Otherwise send it back + with what to fix, in the same session. +3. Release it and close its terminal, as in Step 6. +4. Change the task's checkbox to `[x]`. +5. Commit: `git add && git commit -m "T: history"`. + Only those two paths. Never `git add -A`. +6. Next task. If `--only` was given, or the next task is in a new phase, stop and report. ## Launching a worker @@ -281,7 +295,7 @@ your recommendation. Log the question and the user's answer in `decisions.md`. ## Final report When the run stops (done, `--only`, phase end, or a question), write to your terminal and -to `orchestration/RUNS.md`: +to `orchestration/RUNS.md` (local only, not committed): - Per task: outcome (`done` / `stopped: `), commits (`git log --oneline`), fix rounds used, final test summary line. @@ -291,7 +305,8 @@ to `orchestration/RUNS.md`: ## Files you produce ``` -orchestration/ +HISTORY_.md # committed: one historian section per task +orchestration/ # git-ignored working space, kept on disk RUNS.md # one header + final report per run 0.1/ decisions.md # every decision, question, permission, test result diff --git a/.agents/skills/orchestrate-plan/references/task-specs.md b/.agents/skills/orchestrate-plan/references/task-specs.md index 9b7515b..a3d8d8d 100644 --- a/.agents/skills/orchestrate-plan/references/task-specs.md +++ b/.agents/skills/orchestrate-plan/references/task-specs.md @@ -109,6 +109,26 @@ Do three things: OWNERSHIP and OUTPUT: same as before, but write to . ``` +## Historian: write section + +``` +ROLE: historian for plan task . Your role file says how to work and what the section +looks like. + +PLAN FILE: . The task, copied from the plan: + + +COMMITS: `git log --oneline ..` (the orchestrator's own commits are not in +this range yet). WORKING FOLDER: orchestration// (decisions.md, round-*/ reviews and +fix lists). Task finished: . + +OWNERSHIP: you may edit only . Add exactly one section at its end. Do not +change anything above it. Do not commit. + +ACCEPTANCE: one new section for at the end of , following the role +file's format. In worker_done pass --report-path . +``` + --- ## Report format (all reviewers) diff --git a/.gitignore b/.gitignore index e0eed1d..87eca45 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,6 @@ ENV/ # Mac stuff .DS_Store + +# Orchestrator and reviewer working files (kept locally, summarized in HISTORY_*.md) +orchestration/ diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md new file mode 100644 index 0000000..5caf34b --- /dev/null +++ b/HISTORY_parameter_manager_redesign.md @@ -0,0 +1,135 @@ +# History: Parameter Manager Redesign (Types and Locks) + +One section per finished task, in the order the tasks were done. Written by the historian agent from the commits and the orchestration working files. + +## 0.1 `ParameterGroup` split — 2026-09-23 + +`src/instrumentserver/params.py` now has `ParameterGroup(InstrumentBase)`, a Parameter Group that holds parameters and nested groups and carries the tree helpers moved out of `ParameterManager` (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`/`_to_tree`, `list`, `remove_empty_submodules` and the dotted `add_parameter`/`remove_parameter`/`get`/`set`). `ParameterManager(ParameterGroup)` keeps only the root's job: `workingDirectory`, profiles and file load/save. Submodules made by `_get_parent(..., create_parent=True)` are now plain `ParameterGroup(n)` objects, so creating `q01.IF` no longer lists the working directory or tries to load `parameter_manager-q01.json`. Two new tests in `test/pytest/test_param_manager.py` cover this: `test_submodules_are_groups` and `test_submodule_does_not_load_parameter_file`. + +### Commit by commit +- `46e34cd` The whole task in one commit. It moved the tree helpers into `ParameterGroup`, made `ParameterManager` subclass it, changed `_get_parent` to create `ParameterGroup(n)`, and changed the `_to_tree` assertion to `isinstance(sm, ParameterGroup)`. It also added the two tests. The first test checks that `q01` and `q01.readout` are groups and not managers, and that no "parameter file not found" warning shows up in `caplog`. The second writes a `parameter_manager-q01.json` into `tmp_path` and checks that `q01` does not pick up `file_param`. All 10 existing tests passed without changes (12 passed, full suite 161 passed). All six reviewers approved in round 0 and there were no fix commits. + +### Dropped findings +- test-reviewer-qwen said `test_submodule_does_not_load_parameter_file` never asserts that `q01` is a `ParameterGroup` (should-fix) → dropped because it is wrong: the test in `46e34cd` has `assert isinstance(params.q01, ParameterGroup)`. +- The "no longer lists the working directory" half of the acceptance has no direct assertion. It holds only because `ParameterGroup` has no `refresh_profiles` (raised as a nit by test-reviewer-deepseek and reviewer-deepseek) → not sent. The sibling test already shows that no file logic runs on a submodule. +- `test_submodules_are_groups` runs in the real cwd, so its `caplog` check would pass without testing anything if a `parameter_manager-q01.json` ever sat there (test-reviewer-deepseek, nit) → not sent. It is worth adding `monkeypatch.chdir(tmp_path)` the next time a task touches this file. + +### Loose ends +- A dead local `full_name` in `_get_parent` was already there before this task and moved over unchanged (noted by the coder and plan-checker-deepseek). Left alone as out of scope under plan rule 6. +- The 4 `PytestUnknownMarkWarning`s for the unregistered `integration` mark in `test_apps.py` are out of scope. The `_newOrDeleteParameterDetection` KeyError belongs to task 0.4. + +### Process notes +- Three reviewers ran the full `uv run pytest` at the same time on fixed ports and got noise: a port-5555 `ZMQError` in `test_server_gui.py::test_loading_button`, a setup error in the `param_manager` fixture, and a PyQt crash. Each passed alone or on a rerun, and the orchestrator's own run was green. Recorded for RUNS.md: reviewers should run only the named test file, or take turns on full-suite runs. +- reviewer-deepseek went idle without writing a report or sending worker_done. It finished after a nudge. +- reviewer-deepseek's request to run `git worktree add` into `/tmp` to check the base was rejected, and it was told to use `git show :` instead. +- test-reviewer-qwen sent worker_done twice. Orca rejected the second one. +- The times in `decisions.md` mix two clocks (21:xx and 16:xx entries are interleaved), so the log order there is more reliable than its timestamps. + +## 0.0 Per-run test ports — 2026-09-23 + +`test/pytest/conftest.py` now has a session-scoped `server_port` fixture. It draws a random port from 20000–40000, checks by binding that both `port` and `port + 1` (the Broadcast port) are free, and gives up with a `RuntimeError` after 100 tries. `start_server`, its shutdown `BaseClient`, `cli`, and the tests in `test_client_station.py`, `test_server_gui.py` and `test_gui_navigation.py` all take the port from it, and `AGENTS.md` gained the "Tests never use a fixed port" rule under "Testing". No change to `src/`. The task was added to the plan after the 0.1 pilot, where three reviewers running the suite at the same time collided on port 5555. + +### Commit by commit +- `dcac611` (base, not task code) Tooling commit that added task 0.0 and decision D27 to the plan, together with the orchestration tooling changes (qwen roles moved to `qwen3.8-27b`, `wait-event.sh`, extra opencode permissions). +- `71aa9af` The whole task in one commit. Besides the planned edits, three things the task text did not spell out: + - `test_apps.py` also held the literal 5555, in argparse-default assertions rather than a live server port. The file was not listed in the task, but the acceptance grep covered it, so the orchestrator ruled before dispatch that the grep wins. The four default asserts now compare against `instrumentserver.DEFAULT_PORT`, and the two mocked `parameterManagerScript` tests pass `--port 4567`. + - `test_server_gui.py` gained `_wait_until_client_points_at_server`. The embedded client first connects to the default port and only switches to the real one when the server-started signal arrives. With a fixed default port that race did no harm; with a random port the first request could go to the wrong address. The helper copies the wait `test_gui_navigation._start_window` already had. + - The coder's first version probed sequentially in the OS ephemeral range. Two sessions starting together got overlapping pairs, so it switched to the random wide-range draw. + Checks: the orchestrator's two concurrent `uv run pytest -q` runs both gave 161 passed, and the acceptance grep (with `__pycache__` excluded) found nothing. All six reviewers approved in round 0 with only nits. The fix list was empty, so there were no fix commits. + +### Dropped findings +- The `server_port` docstring says 20000–40000 is "outside the OS ephemeral port range". That holds on macOS but not on Linux, where the range starts at 32768 (reviewer-deepseek, reviewer-qwen, test-reviewer-qwen, plan-checker-qwen, all nit) → not sent. It is a wording problem only, since the bind check still guarantees a free pair. Fix it the next time a task touches `conftest.py`. +- `_wait_until_client_points_at_server` duplicates the wait in `test_gui_navigation.py` (reviewer-deepseek, nit) → not sent; a refactor preference. The other reviewers called the wait a needed race fix. +- `server_port` has no unit test of its own, and a port could be taken between the check and the server's bind (test-reviewer-deepseek, nits; test-reviewer-qwen made the same fixture-test point) → not sent. The plan's test criterion is "whole suite green", and a bad pair fails every test that uses a server, so it cannot fail silently. + +### Questions to Marcos +- plan-checker-qwen: the plan's Testing conventions still said GUI tests use their "own server on a fixed port ≥ 5600", which D27 made stale → raised in the run report (RUNS.md). The plan now says "own server on the `server_port` fixture, never a fixed port, see D27". That edit was committed afterwards in `0fbbddf`. + +### Loose ends +- The acceptance line in the plan was changed on 2026-09-23 from `grep -rn` to `git grep -n "5555\|5599" -- test/pytest`, because the old grep also matched stale `__pycache__` bytecode. During the run the coder deleted the `.pyc` files to make the old grep pass, and the orchestrator ran it with `__pycache__` excluded. +- `test/test_async_requests/test_client.py` and `demo_concurrency.py` still use a literal 5555 (reviewer-qwen). They are outside `test/pytest` and outside this task. + +### Process notes +- reviewer-deepseek's output degenerated into garbage, and it went idle with no report. It went idle a second time after deciding to approve without writing the report. Each time a terminal nudge got it going again, and it finished after the second nudge, about 35 minutes after dispatch. +- The coder's permission requests were mostly `perl -pi` edits of the named test files and three rounds of paired concurrent pytest runs. Each was allowed once. The one out-of-repo write was the pytest logs in opencode's temp dir. +- With the new fixture, reviewers ran the full suite at the same time without the port clashes seen in the 0.1 pilot. + +## 0.2 `Broadcaster` mixin — 2026-09-23 + +`src/instrumentserver/base.py` now has a `Broadcaster` mixin right after `sendBroadcast`. It keeps its sinks in a plain list (`_broadcast_sinks`) and has `add_broadcast_sink(fn)`, `remove_broadcast_sink(fn)` (does nothing if `fn` is not registered) and `broadcast(bp)`. `broadcast` calls each sink in registration order, logs an exception from one sink with `logger.exception` and carries on with the rest, and does nothing when there are no sinks. `ParameterManager` is now `ParameterManager(Broadcaster, ParameterGroup)` and does not broadcast anything yet. The new `test/pytest/test_broadcaster.py` holds the unit part: 9 server-free tests after the fix round. + +### Commit by commit +- `8d04b42` The mixin, the change to `ParameterManager`'s bases plus a docstring paragraph, and 8 tests: no-op without sinks, delivery, registration order, exception isolation (checks one ERROR record naming `failing_sink`, with `exc_info`), removal, removing an unregistered sink, and two tests on a real `ParameterManager` run under `monkeypatch.chdir(tmp_path)`. One change the task text did not ask for: the annotations on the three public methods are strings (`"ParameterBroadcastBluePrint"`). The client builds proxy methods by exec-ing the call-signature string from the blueprint. An unquoted annotation shows up as the dotted `instrumentserver.blueprints.ParameterBroadcastBluePrint`, which the exec'd code cannot resolve, so building any `ParameterManager` proxy would fail with a `NameError`. The class docstring tells Phase 1 to quote annotations the same way, and reviewer-qwen reproduced the failure on its own. Orchestrator run: 8 passed in the file, 169 in the full suite. +- `693d4e7` Fix from round 1: `test_adding_the_same_sink_twice_delivers_twice`. It adds the same sink twice, checks that one `broadcast` reaches it twice, then removes it once and checks that the next `broadcast` reaches it once. test-reviewer-qwen caught the gap (should-fix): the docstring promises "added twice → receives twice", 0.3 builds the Server's sink registration on that promise, and no test checked it. Both test reviewers confirmed the fix in re-review; all six approved with no new findings. Orchestrator run: 9 passed in the file, 170 in the full suite. + +### Dropped findings +- The `_broadcast_sinks` annotation in `__init__` is not quoted, although the docstring says to quote annotations (reviewer-deepseek, nit) → not sent. `__init__` is never proxied. +- `add_broadcast_sink` does not check that `fn` is callable, and the log line in `broadcast` reads `bp.name`/`bp.action`, so it would raise itself if `bp` is not a blueprint (reviewer-qwen, two nits) → not sent. Both only happen when a caller breaks the contract. + +### Loose ends +- For 0.3 (reviewer-qwen, recorded in `decisions.md`): once the Server registers as a sink, a remote client can call `pm.broadcast`, since every public method can be proxied and `deserialize_obj` turns a dict carrying `_class_type` back into a real `ParameterBroadcastBluePrint`. That lets a client put any blueprint it likes on the PUB stream. `add_broadcast_sink` cannot be called over the wire, because a callable does not serialize to JSON. The exposure comes from the plan's "public methods are proxyable" design and is not a 0.2 defect. +- The fact that the client execs signature strings, and so breaks on unquoted class annotations, was left out of scope by the coder. For now the only guard is the docstring rule. + +### Process notes +- plan-checker-deepseek's turn ended on a provider "Upstream error" with no report after about 10 minutes. It finished after a terminal nudge. +- Three permission requests were rejected. reviewer-deepseek asked for `~/.agents/roles` (the role file is in the worktree) and, in re-review, for a garbled path outside the repo. plan-checker-deepseek tried to write its report through a python heredoc whose target path was cut off, and was told to use the file-write tool. + +## 0.3 Server registers sinks — 2026-09-23 + +`StationServer` in `src/instrumentserver/server/core.py` now has `_registerBroadcaster(instrument)`. If the instrument has `add_broadcast_sink`, the helper registers `self._broadcastParameterChange` as a Broadcast sink on it. It is called right after `self.station.add_component(new_instrument)` in `_createInstrument`, and in `__init__` in a loop over every Station component once the config instruments are loaded. A one-line comment above `_instrument_locks` notes that prose calls it the "instrument mutex" (ADR-0003); nothing was renamed. For the tests, `DummyBroadcasterInstrument(Broadcaster, Instrument)` was added to `testing/dummy_instruments/generic.py`. Its `emit_broadcast` method builds a `ParameterBroadcastBluePrint` for `param0`, broadcasts it and returns it. The server part of `test/pytest/test_broadcaster.py` has three tests, which brings the file to 12. + +### Commit by commit +- `04c4cbc` The helper, the two call sites, the mutex comment, `DummyBroadcasterInstrument`, and two tests. `test_created_broadcaster_instrument_reaches_subclient` creates `bcaster` through `cli.find_or_create_instrument` and checks that the Server's sink is in the instrument's `_broadcast_sinks`. It then calls `emit_broadcast` through the proxy and checks that a `SubClient` gets exactly one Broadcast with the right name, action, value and unit. Exactly one means the Server registered only once. `test_plain_dummy_instrument_still_works_and_gets_no_sink` sets and gets `param0` on the plain dummy and checks that the server-side object has no `add_broadcast_sink`. The file also gained the `capture_broadcasts` / `wait_for_broadcasts` helpers, copied from `test/docs_verification/helpers.py` but using the `server_port` fixture's Broadcast port. The coder pointed out that nothing tested the config-load loop. Orchestrator run: 11 passed in the file, 172 in the full suite. +- `5167241` Fix from round 1, test only: `test_config_loaded_broadcaster_instrument_gets_sink`. The test writes a config YAML with a `cfg_bcaster` `DummyBroadcasterInstrument` (`initialize: True`) to `tmp_path` and runs it through `loadConfig`. It then builds a `StationServer` directly, without starting it, and checks that the component is a `Broadcaster` with exactly one sink, the Server's. The teardown closes the temp file, the wake-up socket pair and the instrument. Both test reviewers caught the gap: test-reviewer-qwen called it must-fix and test-reviewer-deepseek should-fix. The plan's Testing table says `test_broadcaster.py` covers "created **and** config-loaded instruments", but the `__init__` loop could be deleted with every test still green. During the fix the coder removed the loop for a moment to show that the new test fails without it. The commit leaves the loop in place, and both test reviewers confirmed the new test fails when the loop is removed. The coder skipped the optional `SubClient` check because a server that was never started has no bound PUB socket. The created-instrument test already covers the wire path. All six reviewers approved in re-review. Orchestrator run: 12 passed in the file, 173 in the full suite. + +### Dropped findings +- plan-checker-qwen pointed out that `_registerBroadcaster` is camelCase, while plan rule 8 says new methods are snake_case → not sent. The task text gives that exact name. +- plan-checker-deepseek noted that the `__init__` loop registers on every Station component, not only the ones loaded from config → not sent. Right after `__init__` the Station holds only the config-loaded instruments, and this matches ADR-0003. +- test-reviewer-qwen noted that "gets no sink" is checked with `hasattr(add_broadcast_sink)`, which tests the dummy's class rather than the Server's behaviour → not sent. That check is valid because registration depends on that same `hasattr`. + +### Loose ends +- Instruments that `_runInitScript` adds to the Station (it runs from `startServer`, after the `__init__` loop) get no sink (plan-checker-qwen, reviewer-qwen). The plan names only two entry points, so 0.3 followed it. This is question 1 for Marcos in `orchestration/RUNS.md` (run_e6f4c00ea2df): add a small follow-up task or accept the gap. Neither the working folder nor the plan records an answer yet. +- The 0.2 note has now come true: the Server is a sink, so a client can call `broadcast` on a Broadcaster proxy and put a Broadcast of its own on the PUB stream (reviewer-qwen). reviewer-qwen called it inert in practice, because callables do not survive JSON and a malformed `broadcast(dict)` goes down the logged sink-error path. This is question 2 in the same RUNS.md entry, also unanswered. +- `capture_broadcasts` / `wait_for_broadcasts` now exist in two places. RUNS.md suggests moving them into `conftest.py` once a third copy is needed (1.3 / 2.5). + +### Process notes +- reviewer-deepseek went idle in round 0 after "Let me write my report", with no report and no worker_done. It finished after a nudge. +- In re-review, test-reviewer-deepseek stalled on a provider "Upstream error", and plan-checker-deepseek's output turned into garbage. Neither left a report. Both finished after nudges. +- test-reviewer-deepseek asked to run a garbled command with `mv` and broken redirections. It was rejected, and the reviewer was told to use the file-write tool. + +## 0.4 Pre-existing fixes (D24, first two) — 2026-09-23 + +Two of the three pre-existing defects listed in D24 are fixed. In `src/instrumentserver/server/core.py`, `StationServer._newOrDeleteParameterDetection` now reads `kwargs.get("initial_value")` and `kwargs.get("unit", "")`. Before, a proxied `add_parameter("x")` with no initial value or unit raised a `KeyError` on the server, and the client got an error back. In `src/instrumentserver/apps.py`, `parameterManagerScript` passes `sub_port=args.port + 1, sub_host="localhost"` to `ParameterManagerGui`, so the GUI's Broadcast listener follows `--port` and no longer stays on the default port. The new test `test_add_parameter_without_initial_value_succeeds_and_broadcasts` in `test/pytest/test_param_manager.py` covers the first fix, and the two existing launcher tests in `test_apps.py` cover the second. + +### Commit by commit +- `56ece34` The whole task in one commit. One change the task text did not ask for: the launcher's `--port` argument got `type=int`. Without it, a port given on the command line stays a string and `args.port + 1` raises `TypeError`. All three reviewers who raised it (both plan checkers and reviewer-deepseek) said it is needed for the expression the plan gives. Only this parser changed; `serverScript` and the other launchers still pass the port as a string, and `test_server_script_passthrough_args` still pins that. `test_param_manager_script_instrument_exists` and `test_param_manager_script_instrument_missing` now assert `ParameterManagerGui` is called with `mock_pm, sub_port=4568, sub_host="localhost"` for `--port 4567`. The new proxy test calls `params.add_parameter("x")` through the `param_manager` fixture and checks that `x` exists and that a `SubClient` on `server_port + 1` gets exactly one `parameter-creation` Broadcast named `parameter_manager.x`, with `value is None` and `unit == ""`. plan-checker-qwen confirmed the test fails on the old code, where the server's error reaches the client as an exception. Orchestrator run: 31 passed in the two files, 174 in the full suite. All six reviewers approved in round 0 with only nits. The fix list was empty, so there were no fix commits. + +### Dropped findings +- `capture_broadcasts` / `wait_for_broadcasts` are copied word for word from `test_broadcaster.py` into `test_param_manager.py` (reviewer-qwen, test-reviewer-qwen, reviewer-deepseek, all nit) → not sent. See Loose ends. +- The launcher tests cover only `--port 4567`, not the default port (test-reviewer-qwen, test-reviewer-deepseek, nit) → not sent. The plan asks only to extend the two existing tests, and that was done. test-reviewer-deepseek's reasoning here is wrong in its details: it calls 4567 "an already-integer port", but `sys.argv` holds a string, so these tests do exercise the `type=int` conversion. + +### Loose ends +- There are now two copies of `capture_broadcasts` / `wait_for_broadcasts`. Move them into `conftest.py` when a third is needed (1.3 / 2.5), as noted after 0.3. +- D24's third item, `ParameterManagerTreeView.onItemNewValue` calling `widget._setMethod(value)`, was left alone as planned. It belongs to task 5.1. + +### Process notes +- plan-checker-deepseek and test-reviewer-deepseek stalled on a provider "Upstream error", and reviewer-deepseek's output turned into garbage. None of them left a report. The orchestrator's first nudge never reached their terminals because of a bug in its own shell command (an empty terminal handle), and it was sent again about 10 minutes later. After that, reviewer-deepseek wrote a full report but hit a provider error before worker_done, test-reviewer-deepseek's report ended in garbage, and plan-checker-deepseek wrote only a skeleton. All three finished after a second, more specific nudge. +- plan-checker-deepseek asked for a garbled `/Users:/Users/...` path outside the repo. It was rejected, and it was told to use the relative report path. + +## 0.5 Broadcast action constants — 2026-09-24 + +`src/instrumentserver/blueprints.py` now defines six string constants for the `action` of a Broadcast: `PARAMETER_UPDATE`, `PARAMETER_CALL`, `PARAMETER_CREATION`, `PARAMETER_DELETION`, `PM_LOCK_UPDATE` and `PM_TYPE_UPDATE`. Their values are the unchanged wire strings (`"parameter-update"` … `"pm-type-update"`). `server/core.py`, `gui/instruments.py` and `client/application.py` use them wherever they used a literal before. `monitoring/listener.py` had no `parameter-` literal and did not change. `test_broadcast_action_constants_pin_the_wire_strings` in `test/pytest/test_broadcaster.py` pins all six values. This was the last task of Phase 0. + +### Commit by commit +- `b3e6586` The constants, with a comment calling them the exact wire strings and saying that the two `PM_*` ones are emitted by Broadcaster instruments. No code emits them yet. The commit also replaces nine literals: the `parameter-update`/`parameter-call` Broadcasts in `StationServer`'s call path, the `parameter-creation`/`parameter-deletion` Broadcasts in `_newOrDeleteParameterDetection`, the four action checks in `ModelParameters.updateParameter`, and the one in `ClientStationGui.listenerEvent`. Three literals were left on purpose, and the orchestrator confirmed the list with `git grep`: the definitions themselves, the comment and log-parsing regex at `log.py:157-158`, and the `action="parameter-update"` default of `DummyBroadcasterInstrument.emit_broadcast` in `testing/dummy_instruments/generic.py`. Orchestrator run: 174 passed in the full suite. +- `eec0c25` Fix from round 1, test only: `test_broadcast_action_constants_pin_the_wire_strings` is a no-server test that asserts each constant equals its wire string. test-reviewer-qwen caught the gap (should-fix). Once every emitter and consumer in the repo shares the constants, a typo in a constant's value would leave the suite green and break external subscribers, and ADR-0003 says those subscribers parse the strings unchanged. test-reviewer-deepseek disagreed and said existing tests already guard the strings. The orchestrator checked with `git grep`: the literals in `test_base.py` and `test_broadcaster.py` compare literals to literals, or to the dummy's literal default, so only `test_param_manager.py:117` tests a string that comes from a constant. The finding was kept. Both test reviewers confirmed the fix in re-review; test-reviewer-deepseek now agreed with the orchestrator's call. All six approved. Orchestrator run: 13 passed in the file, 175 in the full suite. + +### Dropped findings +- `log.py:158` still has `parameter-update` in its log-parsing regex, and the `emit_broadcast` default in `generic.py` keeps its literal (reviewer-qwen, both plan checkers, all nit) → not sent. Neither file is one of the modules the task names, and the wire value is the same. +- The `blueprints.py` comment says the `PM_*` actions are "emitted by" Broadcaster instruments, although no code emits them yet (reviewer-deepseek, nit) → not sent. The comment describes what D10/D26 plan. +- The new test's docstring claims the whole suite would pass if a constant drifted. That overstates it, since `test_param_manager.py:117` already pins `parameter-creation` (reviewer-qwen, re-review nit) → not sent. + +### Process notes +- In round 0, test-reviewer-deepseek stalled on a provider "Upstream error" and plan-checker-deepseek's output turned into garbage. Neither left a report. After nudges, test-reviewer-deepseek wrote its report but stopped before worker_done, and plan-checker-deepseek hit two more provider errors, the last one on its report write. Both finished after further nudges. +- Four permission requests were rejected: reviewer-deepseek asked for `/tmp` and then sent a garbled request; test-reviewer-deepseek sent a garbled request; and in re-review plan-checker-deepseek asked for a garbled path outside the repo. Each was pointed back to writing its report file. +- In re-review, test-reviewer-deepseek's worker_done text came through garbled, but its report was at the right path. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 53bc2f8..1452637 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -40,9 +40,10 @@ Each work session starts fresh from this document. 6. **Commit atomic units of work; never push.** All commits go on branch `marcosfrenkel/new-param-manager`. The coder commits code and tests, and only when the task's named tests pass. The first implementation of a task is one commit; each round - of review fixes is its own commit. The orchestrator commits only review reports and - decision logs under `orchestration//` and this file's checkboxes; reviewers never - commit. + of review fixes is its own commit. The orchestrator commits only the task's section in + `HISTORY_parameter_manager_redesign.md` (written by the historian agent from the + reviews and decision log, which stay in the git-ignored `orchestration/` folder) and + this file's checkboxes; reviewers never commit. Every commit message starts with the task number (`0.1: split ParameterGroup out of ParameterManager`). Never amend, squash, rebase or push: Marcos reads the history commit by commit afterwards. diff --git a/orchestration/0.0/decisions.md b/orchestration/0.0/decisions.md deleted file mode 100644 index c703a18..0000000 --- a/orchestration/0.0/decisions.md +++ /dev/null @@ -1,93 +0,0 @@ -# 0.0 Per-run test ports — decisions log - -Run: run_da269441b6ac. Branch: marcosfrenkel/new-param-manager. Base commit: dcac611241cfbf698885d126a67e8fe11332ffc0. - -## Workers - -| agent id | terminal handle | current dispatch id | -|---|---|---| -| coder | term_265a1d63-a53f-4c89-9103-d08073b3b0b3 | ctx_6c5e0216219c (task_68f47a6717b1, first implementation) | -| reviewer-deepseek | term_81965952-a904-424d-b7a4-bf6a360895ac | ctx_8be3180e5d27 (task_695911280dbd, round 0) | -| reviewer-qwen | term_d1192fcb-3292-40e1-ad8e-6043a40f7464 | ctx_75ea9094771f (task_010246861426, round 0) | -| test-reviewer-deepseek | term_6d9a052f-618c-4324-8cb8-35bdd7ddee25 | ctx_53f1564807de (task_af96c8b4bdc3, round 0) | -| test-reviewer-qwen | term_d71ca1ad-f33f-4554-8a5e-b7f7ea729d0f | ctx_24e43f066a17 (task_581a2dfb9397, round 0) | -| plan-checker-deepseek | term_80d55039-74cd-4161-a984-f9962b009a99 | ctx_a19a7968ea5d (task_ccd049dd0017, round 0) | -| plan-checker-qwen | term_7ed74875-6931-4360-bccc-20e42423ada6 | ctx_9fdc13e828f5 (task_78ca9a1a4ef8, round 0) | - -## Log - -- 16:51 Run created. Checkbox 0.0 set to [~]. Base dcac611. -- Orchestrator note before dispatch: `test/pytest/test_apps.py` also contains the literal 5555 (argparse-default assertions, not a live server port) but the task text does not list it, while the acceptance grep requires the literal to be gone from test/pytest. Decision: the acceptance line is the rule; the spec tells the coder to satisfy it for test_apps.py without changing src/ (e.g. derive the expected default from the parser or a src constant). Logged here so reviewers can check it. -- 16:52 Coder dispatched for first implementation (task_68f47a6717b1 / ctx_6c5e0216219c). -- 16:53 Permission: coder asked to run chained rg/grep caller-check search over src/ and test/. Allowed once: read-only. -- 16:54 Permission: coder asked to run perl -pi replacing port=5555 with port=server_port in test/pytest/test_client_station.py. Allowed once: edit of a test file the task names, equivalent to the edit tool. -- 16:55 Permission: coder asked to run perl -pi adding the server_port fixture to test signatures in test/pytest/test_client_station.py. Allowed once: edit of a named test file. -- 16:55 Permission: coder asked to run perl -pi on test/pytest/test_server_gui.py (server_port fixture + startServerGuiApplication(port=server_port)). Allowed once: edit of a named test file. -- 16:55 Permission: coder asked to run perl -pi on test/pytest/test_gui_navigation.py (server_port fixture threading). Allowed once: edit of a named test file. -- 16:56 Permission: coder asked to run the acceptance grep chained with echo. Allowed once: read-only. -- 16:56 Permission: coder asked to delete test/pytest/__pycache__/*.pyc before the acceptance grep (stale bytecode matched the literal). Allowed once: regenerable files inside the repo, untracked. -- 17:03 Permission: coder asked to run lsof/ps to check for leftover servers on 5555. Allowed once: read-only. -- 17:03 Permission: coder asked to run perl -pi on test/pytest/test_server_gui.py adding a wait helper after startServerGuiApplication. Allowed once: edit of a named test file. -- 17:05 Permission: coder asked to run two concurrent 'uv run pytest -q' with logs in opencode's temp dir (the acceptance check the spec asked for). Allowed once: test command, logs outside repo but in the runner's own temp dir. -- 17:07 Permission: coder asked to rg its two pytest logs for port lines and failures. Allowed once: read-only. -- 17:08 Permission: coder re-ran the two concurrent 'uv run pytest -q' (second attempt after a fix). Allowed once: same as before. -- 17:10 Permission: coder ran the two concurrent pytest runs a third time (run3/run4 logs). Allowed once. -- 17:11 Permission: coder asked pyc cleanup + acceptance grep + git status/diff --stat. Allowed once: read-only apart from regenerable bytecode. -- Coder worker_done (succeeded). Commit 71aa9af "0.0: per-run test ports via session-scoped server_port fixture"; files: AGENTS.md, test/pytest/{conftest,test_apps,test_client_station,test_gui_navigation,test_server_gui}.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit, nothing dirty outside orchestration/ and the plan. Coder retained. -- Coder reported: test_apps.py now derives argparse-default asserts from src DEFAULT_PORT (per orchestrator note); test_server_gui.py needed a wait for the embedded client to re-target (race visible only with dynamic ports); first sequential ephemeral-port probing gave overlapping pairs between two sessions, replaced by random wide-range selection. -- Orchestrator acceptance: grep 5555|5599 over test/pytest (excluding __pycache__) -> nothing (exit 1). Two concurrent `uv run pytest -q`: 161 passed, 4 warnings in 59.06s / 161 passed, 4 warnings in 58.64s. -- 17:14 Six reviewers dispatched for round 0 (target dcac611..71aa9af). -- 17:15 Permission: plan-checker-deepseek asked cat decisions.md + git show --stat (read-only). Allowed once. -- 17:15 Permission: plan-checker-qwen asked git show : | sed -n (read-only). Allowed once. -- 17:15 Permission: test-reviewer-qwen asked the acceptance grep (read-only). Allowed once. -- 17:16 Permission: plan-checker-deepseek asked the acceptance grep (read-only). Allowed once. -- 17:16 Permission: reviewer-deepseek asked the acceptance grep (read-only). Allowed once. -- 17:16 Permission: test-reviewer-deepseek asked the acceptance grep via rg (read-only). Allowed once. -- 17:16 Permission: reviewer-qwen asked the acceptance grep (read-only). Allowed once. -- 17:17 Permission: test-reviewer-qwen asked rg for port literals + ls (read-only). Allowed once. -- 17:17 Permission: plan-checker-deepseek asked grep for remaining fixed-port call sites (read-only). Allowed once. -- 17:17 Permission: test-reviewer-qwen asked rg/cat over test config (read-only). Allowed once. -- 17:18 Permission: plan-checker-deepseek asked grep DEFAULT_PORT in src (read-only). Allowed once. -- 17:18 Permission: test-reviewer-qwen asked cat pytest.ini / grep pyproject / ls (read-only). Allowed once. -- 17:18 Permission: plan-checker-qwen asked the acceptance grep via rg (read-only). Allowed once. -- 17:18 Permission: test-reviewer-qwen asked ls round-0 + git diff --stat src/ (read-only). Allowed once. -- 17:19 Permission: plan-checker-deepseek asked wider grep for port literals in test/ (read-only). Allowed once. -- 17:19 Permission: plan-checker-qwen asked ls __pycache__ (read-only). Allowed once. -- 17:19 Permission: reviewer-qwen asked git status + diff --name-only (read-only). Allowed once. -- 17:20 Permission: plan-checker-deepseek asked port-literal grep over test/ (read-only). Allowed once. -- 17:20 reviewer-qwen worker_done (succeeded, approve, 1 nit). Retained. -- 17:20 Permission: test-reviewer-deepseek asked ls round-0 (read-only). Allowed once. -- 17:21 Permission: test-reviewer-qwen asked access to opencode's temp dir to write two concurrent pytest logs (verifying acceptance); same as allowed for the coder, outside the repo, no repo writes. Allowed once. -- 17:21 Permission: test-reviewer-qwen ran the two concurrent pytest runs with logs in opencode's temp dir (shell-command half of the previous request). Allowed once. -- 17:21 Permission: test-reviewer-deepseek asked its own orca orchestration check (suffix broke the allowlist). Allowed once. -- 17:22 Permission: plan-checker-deepseek asked grep -c + git log/diff on src (read-only). Allowed once. -- 17:22 test-reviewer-deepseek worker_done (succeeded, approve, 2 nits). Retained. -- 17:22 Permission: plan-checker-deepseek asked git show : | grep DEFAULT_PORT (read-only). Allowed once. -- 17:23 Permission: plan-checker-qwen re-ran the acceptance grep (read-only). Allowed once. -- 17:23 Permission: test-reviewer-qwen asked git diff | rg for weakened tests (read-only). Allowed once. -- 17:23 test-reviewer-qwen worker_done (succeeded, approve, 1 nit). Retained. -- 17:24 Permission: plan-checker-qwen asked acceptance grep + git diff --stat src (read-only). Allowed once. -- 17:25 Permission: plan-checker-qwen asked rg over the three touched test files (read-only). Allowed once. -- 17:26 plan-checker-qwen worker_done (succeeded, approve, 2 nits; flags the plan's Testing line 'own server on a fixed port >= 5600' as now stale vs D27). Retained. -- 17:27 plan-checker-deepseek worker_done (succeeded, approve, 2 nits). Retained. -- 17:37 reviewer-deepseek: model output degenerated into garbage, turn ended idle (liveness live) with no report and no worker_done after ~10 min. Nudged in its terminal to write the report and send worker_done. -- 17:38 Permission: reviewer-deepseek asked two concurrent pytest runs on two test files (test command). Allowed once. -- 17:39 Permission: reviewer-deepseek asked ls of orchestration/0.0 (read-only). Allowed once. -- 17:49 reviewer-deepseek: second idle stop after concluding approve without writing the report. Second nudge sent. -- 17:51 reviewer-deepseek worker_done after second nudge (succeeded, approve, 2 nits). Retained. All six round-0 reports present. - -## Round 0 merge (six reports, all `approve`) - -- Docstring of `server_port` says the 20000-40000 range is "outside the OS ephemeral port range", true on macOS only (Linux starts at 32768). Raised as nit by reviewer-deepseek F1, reviewer-qwen F1, test-reviewer-qwen F1, plan-checker-qwen F1. All rated nit; behaviour is protected by the bind check both models confirm. Not sent: nit (wording). Worth fixing when a later task touches conftest.py. -- `_wait_until_client_points_at_server` in test_server_gui.py duplicates the wait in test_gui_navigation._start_window. reviewer-deepseek F2 (nit), noted approvingly by plan-checker-deepseek F2 and both test reviewers as a necessary race fix. Not sent: nit (refactor preference). -- test_apps.py `--port 4567` argv literal in two mocked launcher tests. plan-checker-qwen F2, plan-checker-deepseek F1 (both nit, both say no fix required; clients are MagicMocks, no socket). Not sent: nit. -- Fixture has a check-to-bind window (test-reviewer-deepseek F1, nit) and no dedicated fixture unit test (test-reviewer-deepseek F2, nit; test-reviewer-qwen notes the same and accepts it since the plan names none). Not sent: nit; plan's test criterion is "whole suite green". -- plan-checker-qwen question for the user: the plan's Testing section still says GUI tests use "own server on a fixed port >= 5600", which D27 made stale. Plan text change, so not sent to the coder; raised to the user in the run report. -- Fix list: EMPTY. Task goes to finish. - -## Finish - -- All seven workers released (Orca kept the externally created terminals: state retained, processAction none) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_da269441b6ac: 0 rows. -- Checkbox 0.0 set to [x]. - -**Summary.** Outcome: done. Commits: `71aa9af 0.0: per-run test ports via session-scoped server_port fixture`. Fix rounds used: 0. Tests (orchestrator run, two concurrent): `uv run pytest -q` -> 161 passed, 4 warnings in 59.06s / 161 passed, 4 warnings in 58.64s. Acceptance grep for 5555|5599 in test/pytest: nothing. diff --git a/orchestration/0.0/round-0/fix-list.md b/orchestration/0.0/round-0/fix-list.md deleted file mode 100644 index e3eb523..0000000 --- a/orchestration/0.0/round-0/fix-list.md +++ /dev/null @@ -1,3 +0,0 @@ -# 0.0 — fix list after round 0 - -Empty. All six reviewers returned `approve`; every finding was a nit. See decisions.md for the per-finding log. diff --git a/orchestration/0.0/round-0/plan-checker-deepseek.md b/orchestration/0.0/round-0/plan-checker-deepseek.md deleted file mode 100644 index b5a3336..0000000 --- a/orchestration/0.0/round-0/plan-checker-deepseek.md +++ /dev/null @@ -1,23 +0,0 @@ -# 0.0 — plan-checker-deepseek — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/test_apps.py:354,379 (the two `parameterManagerScript` tests) -- What: the plan task text lists only `test_client_station.py`, `test_server_gui.py`, `test_gui_navigation.py` for the port change; `test_apps.py` is handled per the orchestrator note, so the coder swapping `--port 5555` → `--port 4567` in these two tests is within the logged decision, not in-scope. -- Why: Orca decisions.md records: "the task text does not list that file but its acceptance grep requires the literal gone from all of test/pytest, so the orchestrator told the coder to ... derive the expected default from src without changing src/." The `4567` makes the grep literal-free while src stays untouched (verified `git diff dcac611..71aa9af -- src/` is empty and `DEFAULT_PORT = 5555` exists unchanged in `src/instrumentserver/__init__.py`). -- Suggested fix: none required. - -### F2 — nit -- Where: test/pytest/test_server_gui.py:25-30 (`_wait_until_client_points_at_server`) -- What: a small wait helper added beyond the literal task text (which only says the five `startServerGuiApplication()` calls use the port) so the embedded client re-targets the dynamic port before the first request. -- Why: the plan's conftest prose states the server binds `port` and uses `port + 1` for broadcasts; with fixed ports the embedded client's initial default-port connection happened to be harmless, but a dynamic port makes the first request race. The helper makes the named tests honest without touching `src/`, consistent with "No change to `src/`" ("No change to `src/`."). -- Suggested fix: none required; reads as necessary accommodation for dynamic ports. - -## Notes -- Tests run: `uv run pytest -q` — `161 passed, 4 warnings in 59.12s` (warnings are pre-existing unknown pytest.mark.integration marks). Matches the orchestrator's two concurrent runs (161 passed each). -- Confirmed acceptance: `grep -rn "5555\|5599" test/pytest` (excluding __pycache__) returns nothing (exit 1). -- Remaining `5555`/`5599` literals elsewhere in `test/` (`test_config.py` `:5556` externalBroadcast yaml-content assertion, `test_async_requests/*`, `test/notebooks`, `docs_verification/helpers.py`) are outside the `test/pytest` acceptance scope and are not live server ports targeted by this task; correctly left alone. -- The `server_port` fixture ranges 20k–40k, out of the OS ephemeral range, and verifies both the request port and `port + 1` are free before returning — satisfies "picks two free consecutive ports once per pytest session". \ No newline at end of file diff --git a/orchestration/0.0/round-0/plan-checker-qwen.md b/orchestration/0.0/round-0/plan-checker-qwen.md deleted file mode 100644 index 709d1d6..0000000 --- a/orchestration/0.0/round-0/plan-checker-qwen.md +++ /dev/null @@ -1,28 +0,0 @@ -# 0.0 — plan-checker-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/conftest.py:19-20 -- What: the `server_port` docstring says the random range is "deliberately outside the OS ephemeral port range", which is only true on macOS (49152–65535); on Linux the default ephemeral range (32768–60999) overlaps the top of the 20000–40000 range. -- Why: cosmetic inaccuracy only — the bind-check on `port` and `port + 1` is authoritative, satisfying the task's "picks two free consecutive ports once per pytest session", so behaviour is unaffected. -- Suggested fix: soften the docstring to "drawn from a wide range and verified free by binding" without the platform-specific claim. - -### F2 — nit -- Where: test/pytest/test_apps.py:357, test/pytest/test_apps.py:382 -- What: the two param-manager launcher tests use a new arbitrary fixed literal `"4567"` in `sys.argv` instead of a fixture-derived port. -- Why: the AGENTS.md line added by this task says "Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel." — but these tests mock `Client` and bind nothing, so no live port is involved and the acceptance grep (5555/5599) is clean; `server_port` would also be meaningless here. Flagging only for future consistency (the pre-existing `9000` at line 334/346 is the same kind of mocked-argv literal). -- Suggested fix: none required; optionally note in a comment that the value is inert argv for a mocked launcher. - -## Notes - -- Scope check: commit 71aa9af touches exactly `AGENTS.md`, `test/pytest/conftest.py`, `test/pytest/test_apps.py`, `test/pytest/test_client_station.py`, `test/pytest/test_gui_navigation.py`, `test/pytest/test_server_gui.py` — `git diff --stat dcac611..71aa9af -- src/` is empty, matching "No change to `src/`". -- Task items, point by point: session-scoped `server_port` fixture added (random range + both ports bind-verified, matching "the server binds `port` and uses `port + 1` for broadcasts" — confirmed `self.broadcastPort = self.port + 1` in `server/core.py:152`); `start_server`, the shutdown `BaseClient(port=server_port)` and `cli`/`Client(port=server_port)` all use it; all six `ClientStation(port=5555)` and the `"5555"` assert in `test_client_station.py` converted; all five `startServerGuiApplication()` calls in `test_server_gui.py` now pass `port=server_port` (plus a `_wait_until_client_points_at_server` helper — justified: the embedded client re-targets its port asynchronously after the server-started signal, so without it the tests race only under dynamic ports; orchestrator decisions.md logged this); `TEST_PORT = 5599` removed from `test_gui_navigation.py` with the port threaded through `_start_window(qtbot, port)` and all five tests. -- `test_apps.py` was not in the task text; per the orchestrator decision (orchestration/0.0/decisions.md) the acceptance grep is the rule, and the coder satisfied it by importing `DEFAULT_PORT` from `instrumentserver` (`DEFAULT_PORT = 5555` in `src/instrumentserver/__init__.py`) for the four argparse-default assertions — `src/` untouched, as instructed. -- AGENTS.md: the mandated sentence "Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel." is present verbatim under "Testing", plus one extra bullet listing the `server_port` fixture in the "Test Setup" list — in an in-scope file, directly on-topic, not a scope violation. -- Acceptance: `grep -rn "5555\|5599" test/pytest` → no matches (exit 1), verified twice, including after `__pycache__` was regenerated by the test run. Whole suite: `uv run pytest -q` → `161 passed, 4 warnings in 58.71s` (single run, as my spec preferred); the two-concurrent-runs acceptance was already verified by the orchestrator (decisions.md: 161 passed / 161 passed in two concurrent `uv run pytest -q`). -- Commit hygiene: exactly one commit on `marcosfrenkel/new-param-manager`, message starts with the task number (`0.0: per-run test ports via session-scoped server_port fixture`), no push, no `orchestration/` files inside the commit. -- Vocabulary: no glossary violations in new comments, docstrings or test names ("Server", "Broadcasts" used per the glossary). No protected API touched (no `src/` change). -- Question for the user (plan text, not a code defect): the plan's "Testing" section still says "GUI tests copy the `test_gui_navigation.py` pattern (own server on a fixed port ≥ 5600, `qtbot.waitUntil`)" — D27/task 0.0 deliberately abolished fixed ports, so that line is now stale and would mislead the Phase 5 GUI test tasks; consider updating it to reference the `server_port` fixture. diff --git a/orchestration/0.0/round-0/reviewer-deepseek.md b/orchestration/0.0/round-0/reviewer-deepseek.md deleted file mode 100644 index 033186a..0000000 --- a/orchestration/0.0/round-0/reviewer-deepseek.md +++ /dev/null @@ -1,26 +0,0 @@ -# 0.0 — reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/conftest.py:19-22 (docstring), 35 (`random.randrange(20_000, 40_000)`) -- What: The fixture docstring claims the chosen range is "deliberately outside the OS ephemeral port range", but 20000-40000 overlaps the Linux ephemeral range (which starts at 32768). -- Why: The claim is factually wrong on Linux, where 32768-40000 is inside the OS's ephemeral allocation. The real protection is the bind-check of both ports before returning, which is sound; the docstring just misstates it. -- Suggested fix: Rephrase the docstring to say the range is wide and random and that both ports are verified free by binding them before returning (drop the "outside the ephemeral range" claim, or pick a range truly below Linux's 32768 lower bound). - -### F2 — nit -- Where: test/pytest/test_server_gui.py:25-29 -- What: `_wait_until_client_points_at_server` duplicates the wait-for-client-retarget guard already in `test_gui_navigation._start_window` (test/pytest/test_gui_navigation.py:37-42). -- Why: Two copies of the same timing logic; latent drift risk if the client retarget behaviour changes. -- Suggested fix: Optional — factor into a shared helper in conftest.py, or leave; it is correct as written. - -## Notes - -- Acceptance grep is satisfied: `grep -rn "5555\|5599" test/pytest` returns nothing (remaining 5555/5599 literals elsewhere in test/ are outside test/pytest, in the not-pytest integration scripts, notebooks, a docs-verification helper comment, and test_config.py config-string assertions — all out of this task's scope and none bound a live port). -- test_apps.py now asserts the launcher default via `instrumentserver.DEFAULT_PORT`, so the 5555 literal is gone without any src/ change, as the orchestrator instructed. -- Tests I ran (whole suite): `uv run pytest -q` → `161 passed, 4 warnings in 58.80s`. The 4 warnings are a pre-existing unknown-mark warning for `pytest.mark.integration` in test_apps.py, unrelated to this change. -- Parallel acceptance check: two concurrent `uv run pytest` runs over test_basic_functionality.py + test_client_station.py both passed (16 passed each), confirming the per-session port pair prevents collision. -- The server_port fixture bind-checks both `port` and `port+1` before returning, and the shutdown client in `start_server` now uses the same server_port; `server_port` is session-scoped so every consumer in a session agrees on one port pair. -- No must-fix or should-fix findings. \ No newline at end of file diff --git a/orchestration/0.0/round-0/reviewer-qwen.md b/orchestration/0.0/round-0/reviewer-qwen.md deleted file mode 100644 index fab447e..0000000 --- a/orchestration/0.0/round-0/reviewer-qwen.md +++ /dev/null @@ -1,23 +0,0 @@ -# 0.0 — reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/conftest.py:18-22 (docstring) and :35 (`random.randrange(20_000, 40_000)`) -- What: The fixture docstring says the random range is "deliberately outside the OS ephemeral port range", but 32768–39999 falls inside Linux's default ephemeral range (`ip_local_port_range` 32768–60999); the claim only holds on macOS/BSD (49152–65535). -- Why: The bind-verify loop catches ports that are in use at check time, so the practical risk is small, but on Linux the top quarter of the range still carries the kernel-allocation race the docstring says the range avoids, and the stated rationale is inaccurate there. No observed failure on this machine. -- Suggested fix: Either narrow the upper bound (e.g. `random.randrange(20_000, 32_000)`), or soften the docstring to "outside the macOS ephemeral port range; the bind check covers the rest". - -## Notes - -- Scope check: commit 71aa9af touches only `AGENTS.md` and five `test/pytest/*.py` files; no `src/` change, matching the task's "No change to `src/`". -- Plan conformance (spot-checked here, plan checker owns the full call): session-scoped `server_port` fixture picking two free consecutive ports by binding both (`conftest.py:12-38`); `start_server` (`startServer(port=server_port)`), the shutdown `BaseClient(port=server_port)`, and `cli` (`Client(port=server_port)`) all use it. `test_client_station.py`: six `ClientStation(port=server_port)` and the `"5555"` assert now `str(server_port)`; `test_server_gui.py`: five `startServerGuiApplication(port=server_port)`; `test_gui_navigation.py`: `TEST_PORT = 5599` gone, port threaded through `_start_window`. AGENTS.md carries the plan's sentence verbatim under "Testing" plus a fixture-list entry. -- The new `_wait_until_client_points_at_server` (test_server_gui.py:25-29) and its twin in `test_gui_navigation.py:_start_window` (:42) are justified, not just cosmetic: `EmbeddedClient` is constructed at `DEFAULT_PORT` (server/application.py:639) and only re-targets on the `serverStarted` signal. I verified the wait is safe: the server binds the ROUTER socket *before* emitting `serverStarted` (server/core.py:213-217), so by the time the wait passes the first request is answered; ZMQ DEALER queues outgoing messages until the TCP handshake completes, and the PUB socket on `port + 1` binding a moment later (core.py:220-223) is irrelevant to these tests, whose only dependency is the request socket. -- `endswith(f":{port}")` is exact enough: the leading `:` in the suffix rules out false matches against a shorter default-port address (e.g. port 35555 vs. the initial `:5555`). -- test_apps.py: per the logged orchestrator decision, argparse-default assertions now compare against `DEFAULT_PORT` from `instrumentserver` (src `__init__.py:25`). This is a stronger invariant than the literal (it catches drift between the hardcoded `default=5555` in apps.py:128/155/167 and the package constant), and no src/ change was needed. The `"4567"` argv values in the two mocked `parameterManagerScript` tests match the file's existing style (`"9999"` in `test_server_script_passthrough_args`) and are never bound. -- No test in `test/pytest/` constructs a `Client`/`BaseClient`/`SubClient` outside the fixtures (grep-verified), so nothing silently points at 5555 anymore. `test_base.py` already used `bind_to_random_port`; `test_shortcuts.py` (`{"port": 8000}`) and `test_config.py` (`:5556`) are YAML data values, not live ports, and are outside the acceptance grep. -- `test/test_async_requests/test_client.py:9` and `demo_concurrency.py:33` still use literal 5555; the task text and its acceptance grep are scoped to `test/pytest`, so this is outside 0.0's scope (flagging for the plan checker's awareness only). -- Tests run: `uv run pytest -q` → `161 passed, 4 warnings in 58.68s` (warnings are pre-existing, e.g. unregistered `integration` mark). The two-concurrent-runs acceptance was already executed by the orchestrator and logged in orchestration/0.0/decisions.md (both 161 passed). -- Acceptance grep: `grep -rn "5555\|5599" test/pytest` → no matches (exit 1). diff --git a/orchestration/0.0/round-0/test-reviewer-deepseek.md b/orchestration/0.0/round-0/test-reviewer-deepseek.md deleted file mode 100644 index 39d9b47..0000000 --- a/orchestration/0.0/round-0/test-reviewer-deepseek.md +++ /dev/null @@ -1,60 +0,0 @@ -# 0.0 — test-reviewer-deepseek — round 0 - -Verdict: approve - -Single commit 71aa9af covers exactly the files the task names (conftest.py, the three -listed test modules, test_apps.py per the orchestrator decision, and AGENTS.md), with no -change to `src/`. The acceptance grep `5555|5599` over `test/pytest` (excluding -`__pycache__`) finds nothing, and I confirmed the whole suite passes (161 passed) on the -session-scoped `server_port` fixture. - -## Findings - -### F1 — nit -- Where: test/pytest/conftest.py:25-38 (`server_port` fixture, `_pair_is_free`) -- What: The fixture checks both ports are free, then closes its probe sockets and returns, - so there is a check-to-bind (TOCTOU) window before `startServer` actually binds them. -- Why: Any "pick a free port" scheme has this race; the coder mitigated the dominant - failure mode (concurrently starting sessions getting sequential adjacent ephemeral - ports) by drawing randomly from a wide non-ephemeral range (20_000-40_000), which is the - right call. Not plan-blocking — this is test infra, no `src/` change is allowed by the - task. -- Suggested fix: none required. If ever flaky in CI, hold the probe sockets open and pass - the bound file descriptors to `startServer`, but the current random-range approach is - adequate and within the task's "test-only" scope. - -### F2 — nit -- Where: test/pytest/conftest.py:12-38 -- What: No dedicated test asserts the `server_port` fixture returns a distinct usable - consecutive pair; it is only exercised end-to-end by the suite. -- Why: The task's own test criterion is "whole suite green" and names no fixture unit test; - every `start_server`/`cli`/GUI test now drives the fixture, and the parallel-collision - acceptance is a manual two-run check, so a dedicated test would be a bonus not a rule. -- Suggested fix: optional; a small test that a server started on `server_port` accepts - requests on `port` and broadcasts on `port+1` would pin the fixture contract, but it is - not required for this task. - -## Notes - -Tests run: `uv run pytest -q` in the worktree → `161 passed, 4 warnings in 59.53s` -(matches the orchestrator's two concurrent runs of 161 passed each). - -Per-test check: -- `test_client_station.py` — all six `ClientStation(port=5555)` become - `port=server_port`; the `"5555"` assert becomes `str(server_port)` — still a meaningful - assertion that the GUI widget reflects the actually-used port. Not weakened. -- `test_server_gui.py` — five `startServerGuiApplication()` calls pass `port=server_port` - and gained `_wait_until_client_points_at_server`, which waits for the embedded client's - retarget to the dynamic port before the first request. This is a strengthening (removes a - race that was invisible under the old fixed port), not a loosening. -- `test_gui_navigation.py` — `TEST_PORT = 5599` removed; `_start_window(qtbot, port)` and - each test requests `server_port`; the `addr.endswith(f":{port}")` wait is preserved. - Behaviour unchanged. -- `test_apps.py` — argparse-default asserts derive from `src` `DEFAULT_PORT` (== 5555) - instead of the literal, and two param-manager tests use a non-default `--port 4567` - sentinel; all are mocked unit tests so no live port is involved. Satisfies the - orchestrator decision to make the literal disappear without touching `src/`. -- AGENTS.md carries the required sentence verbatim plus a `server_port` bullet. - -The fixture's `port` / `port + 1` contract matches `src` (server binds `self.port`, -`broadcastPort = self.port + 1`). No test was deleted, skipped, or weakened. \ No newline at end of file diff --git a/orchestration/0.0/round-0/test-reviewer-qwen.md b/orchestration/0.0/round-0/test-reviewer-qwen.md deleted file mode 100644 index 9456b37..0000000 --- a/orchestration/0.0/round-0/test-reviewer-qwen.md +++ /dev/null @@ -1,26 +0,0 @@ -# 0.0 — test-reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/conftest.py:18-23 (`server_port` docstring) -- What: The docstring claims the random range 20000–40000 is "deliberately outside the OS ephemeral port range", which is true on macOS (49152–65535) but not on Linux, where the default ephemeral range starts at 32768, so the top ~7k of the range overlap. -- Why: On Linux, ports in 32768–39999 could be handed out sequentially to a concurrently starting session; the bind-verification of both `port` and `port + 1` catches ports already in use and the random draw makes an adjacent-pair hand-out extremely unlikely, so this is a docstring accuracy issue, not a behaviour one. -- Suggested fix: Soften the docstring (e.g. "outside the macOS ephemeral range; both ports are verified free regardless of platform"). - -## Notes - -- Review target: commit `71aa9af` ("0.0: per-run test ports via session-scoped server_port fixture"), the only commit in `dcac611..71aa9af`. Files: `AGENTS.md`, `test/pytest/{conftest,test_apps,test_client_station,test_gui_navigation,test_server_gui}.py`. `git diff dcac611..71aa9af -- src/` is empty, so the task's "No change to src/" rule holds. -- `server_port` (conftest.py:12-38): session-scoped, draws a random port from 20000–40000 and bind- verifies both `port` and `port + 1` (the server binds `127.0.0.1:port` for requests and `*:port+1` for the PUB broadcast socket — `server/core.py:135,152,214` — so checking `""` (0.0.0.0) for both is a correct superset check). Raises `RuntimeError` after 100 failed draws. It can fail (all draws occupied → every server module errors), and it cannot silently return a used pair. -- Named call sites, all converted: `start_server` (`startServer(port=server_port)`), the shutdown client in `start_server` (`BaseClient(port=server_port)`), `cli` (`Client(port=server_port)`), six `ClientStation(host=..., port=server_port)` in `test_client_station.py` (incl. the module-scoped `client_station` fixture) plus the `"5555"` assert (now `str(server_port)` — meaningful, since `ServerWidget` renders `client_station._port`), five `startServerGuiApplication(port=server_port)` in `test_server_gui.py`, and `TEST_PORT = 5599` removed from `test_gui_navigation.py` (now threaded through `_start_window(qtbot, port)`). -- New behaviour, well tested: `_wait_until_client_points_at_server` (test_server_gui.py:25-29), added after every `startServerGuiApplication(port=...)` call. This addresses a real new race — `EmbeddedClient` is constructed at the default port and only re-targets when the `serverStarted` signal is delivered, so without the wait the first request would go to port 5555 (a developer's live server). `test_gui_navigation.py` already had the equivalent wait pre-existing. -- `test_apps.py` (orchestrator-directed, see `orchestration/0.0/decisions.md`): the four argparse-default assertions now compare against `instrumentserver.DEFAULT_PORT` (= 5555, `src/instrumentserver/__init__.py:25`) instead of the literal — assertion strength preserved, no src change. The two `parameterManagerScript` tests use `"--port", "4567"`; those clients are `MagicMock`s, so no socket is opened — same style as the pre-existing `9999`/`9000` literals in that file and irrelevant to port collisions. -- No weakening: no `skip`/`xfail` introduced; the only removed lines are the port literals; every pre-existing assertion is intact. Other test files in `test/pytest` reach the server only through the `cli`/`param_manager` fixtures, so they inherit the dynamic port; no other fixed server-port literals remain (`test_shortcuts.py` `port: 8000` is YAML config content parsed locally, no server). -- No dedicated unit test exists for the `server_port` fixture itself (freeness, consecutiveness, session stability), but the plan names none ("Tests: whole suite green") and the fixture is self-verifying through use: a non-free pair makes the server's bind fail and every server-backed test in the session fail, and the two-concurrent-runs acceptance below exercises it. -- Tests run (mine): - - `uv run pytest -q` → `161 passed, 4 warnings in 59.41s` (the 4 warnings are pre-existing `PytestUnknownMarkWarning` for `integration`, unrelated to this commit). - - Two concurrent `uv run pytest -q` runs started at the same time: run A `161 passed, 4 warnings in 59.09s` (exit 0), run B `161 passed, 4 warnings in 58.89s` (exit 0) — the plan's second acceptance criterion, independently re-verified. - - Acceptance grep `grep -rn "5555\|5599" test/pytest` → no matches (exit 1). -- AGENTS.md: the exact required line ("Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel.") is present under "Testing", plus a `server_port` bullet in the Test Setup list. diff --git a/orchestration/0.1/decisions.md b/orchestration/0.1/decisions.md deleted file mode 100644 index 57df3bd..0000000 --- a/orchestration/0.1/decisions.md +++ /dev/null @@ -1,56 +0,0 @@ -# 0.1 `ParameterGroup` split — decisions log - -Run: run_caa796369a9e. Branch: marcosfrenkel/new-param-manager. Base commit: 447c7f71542e443410684849084ae230cbc8ecfc. - -## Workers - -| agent id | terminal handle | current dispatch id | -|---|---|---| -| coder | term_4a013f23-1b48-4ca5-a78c-024c66cbf3f3 | ctx_c859551be4f9 (task_708c2eb80a84, first implementation) | -| reviewer-deepseek | term_c67207a2-4d71-42e7-b734-6c82f72661c1 | ctx_5e3ec812a3f5 (task_fd8922c3a974, round 0) | -| reviewer-qwen | term_551d82c0-fbb4-4888-b672-ec27de56b683 | ctx_738d54cd1961 (task_8d4fe0e3d9c7, round 0) | -| test-reviewer-deepseek | term_58b50438-41e6-43cf-a932-890e80f0e6cb | ctx_160f0a68a0ac (task_01e6109d0466, round 0) | -| test-reviewer-qwen | term_d420c0c1-f7f1-4eb4-9d7c-5aae16bcaac1 | ctx_a5219e6abadc (task_9f3e6c1bde9e, round 0) | -| plan-checker-deepseek | term_8611173c-bf5f-4825-9376-50bfbaa57911 | ctx_c1ac9fe459bd (task_ade2afeb0a58, round 0) | -| plan-checker-qwen | term_080e9cf8-55a8-4763-8e05-7c4fc4a6a44e | ctx_27057ebeb71c (task_a0ab54b9de98, round 0) | - -## Log - -- 21:08 Run run_caa796369a9e created. Checkbox 0.1 set to [~]. Base 447c7f7. -- 21:10 Coder dispatched for first implementation (task_708c2eb80a84 / ctx_c859551be4f9). -- 21:13 Permission: coder asked to run `uv run python -c "import qcodes, inspect; ... print(inspect.getsource(InstrumentBase.add_submodule))"`. Allowed once: read-only introspection of an installed library, no writes, no network. -- 21:16 Permission: coder asked to run `uv run python -c "... inspect.getsource(InstrumentBase.__init__) ..."`. Allowed once: read-only introspection. -- 21:19 Permission: coder asked to run `git status && git log --oneline -3 && git branch --show-current`. Allowed once: read-only git, chained with && so the allowlist did not match. -- 21:20 Coder worker_done (succeeded). Commit 46e34cd "0.1: split ParameterGroup out of ParameterManager", files: src/instrumentserver/params.py, test/pytest/test_param_manager.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit. Coder retained. -- 21:22 Orchestrator test run after first implementation: `uv run pytest test/pytest/test_param_manager.py` -> 12 passed in 5.17s; `uv run pytest` -> 161 passed, 4 warnings in 59.62s. -- Coder noted out of scope (not fixed, per plan rule 6): 4 PytestUnknownMarkWarning for unregistered 'integration' mark in test_apps.py; dead local `full_name` in `_get_parent`; the `_newOrDeleteParameterDetection` KeyError is task 0.4. -- 21:26 Six reviewers dispatched for round 0 (target 447c7f7..46e34cd). -- 21:28 plan-checker-qwen worker_done (succeeded, approve, 0 findings). Retained. -- 16:23 reviewer-qwen worker_done (succeeded, approve, 0 must/should-fix). Retained. -- 21:34 Permission: test-reviewer-deepseek asked to run `lsof -nP -iTCP:5555 -sTCP:LISTEN; lsof -nP -iTCP:5599 -sTCP:LISTEN` (port check before running tests). Allowed once: read-only. -- 21:34 Permission: test-reviewer-qwen asked to run `cd && orca orchestration send ... --type worker_done ...` (its own worker_done, the `cd &&` prefix broke the allowlist). Allowed once. -- 21:37 test-reviewer-qwen worker_done (succeeded, approve with 1 should-fix). A second duplicate worker_done was rejected by Orca (capability revoked after the first settled); no action needed. Retained. -- 21:38 Permission: reviewer-deepseek asked to run `cd && sed -n '180,190p' src/instrumentserver/serialize.py`. Allowed once: read-only. -- 21:44 Permission: reviewer-deepseek asked to run `git worktree add /tmp/param_base_base_check_... 447c7f7`. REJECTED: reviewers may not run state-changing git commands, and it writes outside the repo. Told it to use `git show :` instead. -- 16:30 plan-checker-deepseek worker_done (succeeded, approve, 0 must/should-fix, 1 nit). Retained. -- 16:30 test-reviewer-deepseek worker_done (succeeded, approve, 2 nits; its full-suite run hit a port-5555 collision from concurrent reviewer test runs, green in isolation). Retained. -- 16:38 reviewer-deepseek went idle (activity done, liveness live) with no report and no worker_done. Nudged in its terminal to write the report and send worker_done. -- 16:40 reviewer-deepseek worker_done after nudge (succeeded, approve, 0 must/should-fix, 1 nit). Retained. All six round-0 reports present. - -## Round 0 merge (six reports, all `approve`) - -- test-reviewer-qwen F1 (should-fix): "test_submodule_does_not_load_parameter_file does not assert the submodule is a ParameterGroup". One model only; orchestrator read the test: line 257 already has `assert isinstance(params.q01, ParameterGroup)`. DROPPED: not confirmed, factually wrong. -- test-reviewer-deepseek F1 + reviewer-deepseek F1 (both nit): the "no longer lists the working directory" half of the acceptance is proven only by construction (ParameterGroup has no refresh_profiles), not by a direct assertion. Two roles, both rated nit; the sibling test proves no file logic runs on a submodule. Not sent: nit. -- test-reviewer-deepseek F2 (nit): run `test_submodules_are_groups` under `tmp_path` so the caplog assertion cannot go vacuous if a parameter_manager-q01.json sits in cwd. Not sent: nit. Reasonable hardening; can ride along with a later task touching that file. -- test-reviewer-qwen F2 (nit): add a comment on ParameterGroup explaining it has no __init__. Not sent: nit. -- plan-checker-deepseek N1 (nit): pre-existing dead `full_name` local in `_get_parent`. Not sent: nit and out of scope (plan rule 6). -- plan-checker-qwen F1: placeholder "no issues". Nothing to do. -- Full-suite noise seen by reviewers: test-reviewer-deepseek got 1 failed (port 5555 in use), reviewer-deepseek got 1 setup error in the param_manager fixture, plan-checker-deepseek got a PyQt crash on first run. All three ran `uv run pytest` concurrently against fixed ports; each passed in isolation or on rerun, and the orchestrator's own run was 161 passed. Judged environment noise from parallel reviewer test runs, not a code defect. Process note for RUNS.md: tell reviewers to run only the named test file, or stagger full-suite runs. -- Fix list: EMPTY. Task goes to finish. - -## Finish - -- All seven workers released (Orca kept the externally created terminals: state retained, processAction none) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_caa796369a9e: 0 rows. -- Checkbox 0.1 set to [x]. - -**Summary.** Outcome: done. Commits: `46e34cd 0.1: split ParameterGroup out of ParameterManager`. Fix rounds used: 0. Tests (orchestrator run): `uv run pytest test/pytest/test_param_manager.py` -> 12 passed in 5.17s; `uv run pytest` -> 161 passed, 4 warnings in 59.62s. diff --git a/orchestration/0.1/round-0/fix-list.md b/orchestration/0.1/round-0/fix-list.md deleted file mode 100644 index 3a03c8d..0000000 --- a/orchestration/0.1/round-0/fix-list.md +++ /dev/null @@ -1,7 +0,0 @@ -# 0.1 — round 0 — fix list - -Empty. All six reviewers returned `approve`. No must-fix findings. The single should-fix -(test-reviewer-qwen F1) was raised by one model only and was not confirmed by the -orchestrator: `test_submodule_does_not_load_parameter_file` already asserts -`isinstance(params.q01, ParameterGroup)` (test/pytest/test_param_manager.py:257). -Remaining findings are nits and were not sent. See decisions.md. diff --git a/orchestration/0.1/round-0/plan-checker-deepseek.md b/orchestration/0.1/round-0/plan-checker-deepseek.md deleted file mode 100644 index e4695da..0000000 --- a/orchestration/0.1/round-0/plan-checker-deepseek.md +++ /dev/null @@ -1,38 +0,0 @@ -# 0.1 — plan-checker-deepseek — round 0 - -Verdict: approve - -Reviewed commit `46e34cd` ("0.1: split ParameterGroup out of ParameterManager") in `447c7f7..46e34cd` against PLAN_parameter_manager_redesign.md, CONTEXT.md, ADR-0001/0002/0003. The commit touches exactly `src/instrumentserver/params.py` and `test/pytest/test_param_manager.py` — nothing out of scope. - -## Findings - -No must-fix or should-fix findings. - -### N1 — nit -- Where: src/instrumentserver/params.py:99,102 -- What: `_get_parent` still accumulates `full_name` (lines 99, 102) but never uses it. -- Why: Pre-existing dead code carried over unchanged when the method moved into `ParameterGroup`; not introduced by this commit and not a plan violation. -- Suggested fix: Drop the `full_name` bookkeeping when next touching the method. Orchestrator need not forward. - -## Notes - -Plan-task compliance, line by line: -- `ParameterGroup(InstrumentBase)` created at params.py:59 holding all 12 listed tree helpers: `_to_tree` (:72), `to_tree` (:82), `_get_param` (:85), `_get_parent` (:93), `has_param` (:115), `add_parameter` (:122), `remove_parameter` (:149), `get` (:156), `set` (:160), `remove_empty_submodules` (:164), `parameter` (:184), `list` (:192). ✓ -- `ParameterManager(ParameterGroup)` (:208) keeps profiles, files and `workingDirectory` (`__init__` :227, property :239, `refresh_profiles` :291, `fromFile`/`fromParamDict`/`toFile`/`toParamDict` :311-442). No Types/Locks yet — correct for Phase 0.1. ✓ -- `_get_parent(..., create_parent=True)` creates `ParameterGroup(n)` (:109). ✓ -- `_to_tree` assertion changed to `isinstance(sm, ParameterGroup)` (:76); recursion via `cls._to_tree(sm)` still walks nested groups. ✓ -- Acceptance: a `ParameterGroup` has no `refresh_profiles()` and no `fromFile()`, so creating `q01.IF` triggers no `os.listdir(workingDirectory)` and no "parameter file not found" warning, and `isinstance(pm.q01, ParameterGroup)` is True while `isinstance(pm.q01, ParameterManager)` is False — covered by `test_submodules_are_groups` (isinstance asserts + `"parameter file not found" not in caplog.text`). ✓ -- Tests: `test_param_manager.py` all green unchanged; added `test_submodules_are_groups` (test file :220) and `test_submodule_does_not_load_parameter_file` (:239) which plants a `parameter_manager-q01.json` in `tmp_path` and asserts it is NOT loaded into the `q01` submodule (`not params.q01.has_param("file_param")`, `"q01.file_param" not in params.list()`, `params.q01.my_param() == 1`). ✓ - -API-preservation (plan way-of-working rule 7): all of `add_parameter`, `remove_parameter`, `list`, `get`, `set`, `has_param`, `parameter`, `toFile`, `fromFile`, `switch_to_profile`, `refresh_profiles`, `list_profiles`, `to_tree`, `remove_all_parameters`, `remove_empty_submodules` retain their signatures and behaviour on `ParameterManager`. ✓ - -Vocabulary (plan rule 2): new docstrings/comments and test names use glossary terms — "Parameter Group", "Parameter Manager", "Type", "Lock" — consistently. ✓ - -No other call sites in `src/` or `test/` depended on submodules being `ParameterManager` (grep for `_to_tree`, `isinstance(...ParameterManager)`, `ParameterManager(` across the tree confirms only param_manager tests construct submodules via `add_parameter`, which now yield `ParameterGroup`). ✓ - -Scope (plan rule 6 / session step 6): single atomic commit, single task, no out-of-scope fixes. ✓ - -## Tests run - -- `uv run pytest test/pytest/test_param_manager.py` → `12 passed in 5.15s` (all prior tests unchanged-green plus the two new ones). -- `uv run pytest` → `161 passed, 4 warnings in 59.92s`. (A first invocation aborted with a hard PyQt interpreter crash mid-suite in a server-basing test; an immediate re-run completed all 161 green, so the crash is a transient macOS/PyQt flake, not this commit. The affected files were not touched by `params.py`.) \ No newline at end of file diff --git a/orchestration/0.1/round-0/plan-checker-qwen.md b/orchestration/0.1/round-0/plan-checker-qwen.md deleted file mode 100644 index d487b59..0000000 --- a/orchestration/0.1/round-0/plan-checker-qwen.md +++ /dev/null @@ -1,22 +0,0 @@ -# 0.1 — plan-checker-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: N/A -- What: No issues found. -- Why: The commit correctly implements all requirements from task 0.1. -- Suggested fix: N/A - -## Notes -- Tests run: `uv run pytest test/pytest/test_param_manager.py` — 12 passed in 5.20s. -- All acceptance criteria met: - - `ParameterGroup(InstrumentBase)` created with tree helpers moved from `ParameterManager`. - - `ParameterManager(ParameterGroup)` extends `ParameterGroup` with file, profile, workingDirectory logic. - - `_get_parent(..., create_parent=True)` creates `ParameterGroup` instances. - - `_to_tree` assertion changed from `ParameterManager` to `ParameterGroup`. - - Creating `q01.IF` no longer lists working directory or logs "parameter file not found". - - `isinstance(pm.q01, ParameterGroup)` is `True`, `isinstance(pm.q01, ParameterManager)` is `False`. - - `test_submodules_are_groups` and `test_submodule_does_not_load_parameter_file` tests added and pass. diff --git a/orchestration/0.1/round-0/reviewer-deepseek.md b/orchestration/0.1/round-0/reviewer-deepseek.md deleted file mode 100644 index 43681cb..0000000 --- a/orchestration/0.1/round-0/reviewer-deepseek.md +++ /dev/null @@ -1,20 +0,0 @@ -# 0.1 — reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/test_param_manager.py:220 -- What: `test_submodules_are_groups` asserts only the "logs 'parameter file not found'" half of the acceptance clause; it never observes "no longer lists the working directory". -- Why: The acceptance reads "creating `q01.IF` no longer lists the working directory **or** logs 'parameter file not found'". Listing the working directory (calling `refresh_profiles()`) is not directly observable from outside a `ParameterGroup` (it has no `.profiles`), and the second added test (`test_submodule_does_not_load_parameter_file`) already proves no file logic runs on a submodule, so the clause is covered by construction. Not a real gap, hence nit. -- Suggested fix: none strictly needed; if desired, the docstring/comment could note the directory-listing clause is covered inductively via `test_submodule_does_not_load_parameter_file`. - -## Notes - -- The split matches the plan (task 0.1 / D15 / D26): all listed tree helpers (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`/`_to_tree`, `list`, `remove_empty_submodules`, dotted `add_parameter`/`remove_parameter`/`get`/`set`) live in `ParameterGroup`; `ParameterManager(ParameterGroup)` keeps `workingDirectory`, profiles, files, `createFromParamDict`, `remove_all_parameters`. `_get_parent(..., create_parent=True)` builds `ParameterGroup(n)` (params.py:109); `_to_tree` asserts `isinstance(sm, ParameterGroup)` (params.py:76). Acceptance `isinstance(pm.q01, ParameterGroup)` and not `ParameterManager` is satisfied and asserted. -- Both required tests were added and are meaningful regression tests: `test_submodule_does_not_load_parameter_file` would have failed before the change (a full `ParameterManager('q01')` would have loaded `parameter_manager-q01.json` and populated `file_param`). -- External callers of `ParameterManager` are unaffected: the public API surface (add_parameter/remove_parameter/list/get/set/has_param/parameter/to_file/from_file/switch_to_profile/refresh_profiles/list_profiles/to_tree/remove_all_parameters/remove_empty_submodules) is inherited unchanged; src/qodex callers (gui/instruments.py, apps.py, client/proxy.py) only construct/reference the root `ParameterManager`. -- Tests run: - - `uv run pytest test/pytest/test_param_manager.py -q` → `12 passed`. - - `uv run pytest -q` → `160 passed, 4 warnings, 1 error`. The single error is a **setup error** for `test/pytest/test_param_manager.py::test_param` ("Exceptions caught in Qt event loop", with a `serialize.py` TypeError `'1.0 is not an int; Parameter: cs_dummy.int_param1'`). It originates during dummy-instrument serialization in `serialize.py` inside the `param_manager` fixture's server startup and is unrelated to this commit's files; I did not re-run the base to confirm pre-existence since running the base requires a write-outside-repo git worktree (disallowed). \ No newline at end of file diff --git a/orchestration/0.1/round-0/reviewer-qwen.md b/orchestration/0.1/round-0/reviewer-qwen.md deleted file mode 100644 index f4398d0..0000000 --- a/orchestration/0.1/round-0/reviewer-qwen.md +++ /dev/null @@ -1,26 +0,0 @@ -# 0.1 — reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -No findings. The implementation correctly splits `ParameterGroup` out of `ParameterManager`. - -## Notes - -- Tests run: `uv run pytest test/pytest/test_param_manager.py` — 12 passed. -- The new `ParameterGroup` class holds: - - Tree helpers (`_get_param`, `_get_parent`, `has_param`, `parameter`, `to_tree`, `list`, `remove_empty_submodules`) - - Dotted-path methods (`add_parameter`, `remove_parameter`, `get`, `set`) - - No file, profile, Type or Lock logic -- `ParameterManager` now extends `ParameterGroup`, keeping: - - Working directory, profiles, file persistence (`fromFile`, `toFile`, `fromParamDict`, `toParamDict`) - - Profile management (`refresh_profiles`, `switch_to_profile`, `remove_all_parameters`) -- `_to_tree` assertion changed from `isinstance(sm, ParameterManager)` to `isinstance(sm, ParameterGroup)` at line 76. -- `_get_parent(..., create_parent=True)` creates `ParameterGroup(n)` at line 109. -- Two new tests added: - - `test_submodules_are_groups`: verifies submodules are `ParameterGroup` not `ParameterManager`. - - `test_submodule_does_not_load_parameter_file`: verifies parameter files are not loaded into submodules. -- Acceptance criteria met: - - Creating `q01.IF` no longer lists the working directory or logs "parameter file not found". - - `isinstance(pm.q01, ParameterGroup)` is `True` and `isinstance(pm.q01, ParameterManager)` is `False`. diff --git a/orchestration/0.1/round-0/test-reviewer-deepseek.md b/orchestration/0.1/round-0/test-reviewer-deepseek.md deleted file mode 100644 index 98de7b6..0000000 --- a/orchestration/0.1/round-0/test-reviewer-deepseek.md +++ /dev/null @@ -1,25 +0,0 @@ -# 0.1 — test-reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/test_param_manager.py:220 (test_submodules_are_groups) -- What: The "no longer lists the working directory" half of the first acceptance item is only proven transitively through `isinstance(params.q01, ParameterGroup)`, never asserted directly. -- Why: The caplog assertion (`"parameter file not found" not in caplog.text`) covers only the logging clause of the acceptance, not the working-directory listing clause. A hypothetical regression that re-introduced `refresh_profiles()` (listdir) on submodule creation without the `fromFile()` log would slip past both assertions. The plan acceptance reads "creating `q01.IF` no longer lists the working directory or logs 'parameter file not found'". -- Suggested fix: assert directly that creating a submodule does not touch the working directory — e.g. `monkeypatch` `os.listdir` and assert it is not called (or is called only by the root's own `refresh_profiles`) while running `add_parameter("q01.IF", ...)`, or assert `params.profiles` is unchanged by submodule creation. - -### F2 — nit -- Where: test/pytest/test_param_manager.py:220 (test_submodules_are_groups) -- What: The test runs in the repo's real cwd rather than an isolated `tmp_path`. -- Why: `caplog.clear()` then asserting no "parameter file not found" is emitted depends on there being no `parameter_manager-q01.json` in cwd. If such a file is ever present, the log assertion becomes vacuous (no warning is logged because the file exists), leaving only the isinstance assertion to catch a regression. The sibling test (`test_submodule_does_not_load_parameter_file`) already shows the isolated pattern via `monkeypatch.chdir(tmp_path)`. -- Suggested fix: `monkeypatch.chdir(tmp_path)` (and optionally `monkeypatch.chdir` to a fresh dir) at the top of the test so the caplog assertion is environment-independent. - -## Notes -- Both plan-named tests are present and meaningful. `test_submodules_are_groups` would fail on the pre-split code (old `_get_parent` built `ParameterManager(n)` submodules, so `isinstance(q01, ParameterGroup)` is False and submodule `fromFile()` logs "parameter file not found"). `test_submodule_does_not_load_parameter_file` would also fail on old code: the old submodule `ParameterManager("q01")` would load `parameter_manager-q01.json` whose key `q01.file_param` matches its name filter, making `has_param("file_param")` True. Both new tests are local unit tests, matching the plan's `test_param_manager.py` layer (unit, no server). -- The plan's method-split list is respected in the covered behaviour: `ParameterGroup` carries the tree helpers, `ParameterManager` keeps profiles/files/workingDirectory, `_get_parent(create_parent=True)` creates `ParameterGroup(n)`, and the `_to_tree` assertion is `ParameterGroup`. (Plan-conformance detail; flagging only to confirm nothing about tests conflicts.) -- No existing test was weakened, deleted or skipped. -- Tests run: - - `uv run pytest test/pytest/test_param_manager.py` → `12 passed in 5.15s`. The two new tests are the only additions; all 10 pre-existing tests pass unchanged. - - `uv run pytest` → `1 failed, 160 passed` — the single failure `test_server_gui.py::test_loading_button` is `ZMQError: Address already in use (addr='tcp://127.0.0.1:5555')`, a port-fixed collision between server-bound test modules in the same session. It passes in isolation (`1 passed in 5.37s`) and is unrelated to this commit (`test_param_manager.py` uses no server and never binds 5555). \ No newline at end of file diff --git a/orchestration/0.1/round-0/test-reviewer-qwen.md b/orchestration/0.1/round-0/test-reviewer-qwen.md deleted file mode 100644 index 61d6e81..0000000 --- a/orchestration/0.1/round-0/test-reviewer-qwen.md +++ /dev/null @@ -1,20 +0,0 @@ -# 0.1 — test-reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — should-fix -- Where: test/pytest/test_param_manager.py:222 -- What: Test `test_submodule_does_not_load_parameter_file` asserts `not isinstance(params.q01, ParameterManager)` but does not explicitly verify the `q01` submodule is a `ParameterGroup`. -- Why: The acceptance criteria in task 0.1 requires that submodules be `ParameterGroup` instances. The test should assert both types explicitly to match the pattern in `test_submodules_are_groups`. -- Suggested fix: Add `assert isinstance(params.q01, ParameterGroup)` at the start of the test. - -### F2 — nit -- Where: src/instrumentserver/params.py:235 -- What: `ParameterManager.__init__` calls `refresh_profiles()` and `fromFile()` unconditionally, but `ParameterGroup` has no such logic. -- Why: The plan task states submodules should not list the working directory or load files. This behavior is already preserved because only `ParameterManager.__init__` (not `ParameterGroup.__init__`) calls these methods; `ParameterGroup` has no `__init__` and `super().__init__` is never invoked on a `ParameterGroup` instance directly in production code. No change is needed, but adding a clarifying comment in `ParameterGroup` would improve maintainability. -- Suggested fix: Add a comment above `class ParameterGroup` noting that it intentionally has no `__init__` to avoid file/profile logic, and that submodules are created via `_get_parent(..., create_parent=True)` using only `ParameterGroup(n)`. - -## Notes -All tests in `test/pytest/test_param_manager.py` pass (12 tests, including the two new ones). The full suite passes (161 tests). The implementation correctly splits `ParameterGroup` out of `ParameterManager`, making submodules plain containers without file/profile logic, and `isinstance(pm.q01, ParameterGroup)` holds while `isinstance(pm.q01, ParameterManager)` does not. diff --git a/orchestration/0.2/decisions.md b/orchestration/0.2/decisions.md deleted file mode 100644 index d1f437f..0000000 --- a/orchestration/0.2/decisions.md +++ /dev/null @@ -1,96 +0,0 @@ -# 0.2 `Broadcaster` mixin — decisions log - -Run: run_da269441b6ac. Branch: marcosfrenkel/new-param-manager. Base commit: a1bce5e9bf8d2e3be7f6bc9c19f2ec74057e9ed5. - -## Workers - -| agent id | terminal handle | current dispatch id | -|---|---|---| -| coder | term_1bc0a2c5-d415-4f16-ab81-5cbe17bcc329 | ctx_4df4cbab12ba (task_e7ca0f53422e, first implementation) | -| reviewer-deepseek | term_76c13ad0-a01b-4883-8031-68551652ac81 | ctx_f6dcaac7bdc9 (task_747a3183842a, round 0) | -| reviewer-qwen | term_a287d55a-2281-4b23-a8ff-5cfe95d6425d | ctx_93b1c3b50dd8 (task_815cfe2d5939, round 0) | -| test-reviewer-deepseek | term_5fd75ef7-3ffc-45ad-8c44-a359fde259d0 | ctx_103f3ba66b66 (task_f4183c138862, round 0) | -| test-reviewer-qwen | term_e18b9a9e-4f01-43ab-9ba9-a28e21fb4a85 | ctx_f5b3f7a1e804 (task_eeb79235dfbd, round 0) | -| plan-checker-deepseek | term_5785f5b7-8251-459c-952e-3aa76b9ee79d | ctx_630892470a3b (task_78a0a56620eb, round 0) | -| plan-checker-qwen | term_eabc8389-b3c9-4a1a-bbc7-b37f4e78e5eb | ctx_50475d4efdd6 (task_44fa4d0caa54, round 0) | - -## Log - -- 17:52 Checkbox 0.2 set to [~]. Base a1bce5e. -- 17:52 Coder dispatched for first implementation (task_e7ca0f53422e / ctx_4df4cbab12ba). -- 17:53 Permission: coder asked python -c inspect.signature(InstrumentBase.__init__) (read-only introspection). Allowed once. -- 17:53 Permission: coder re-ran the InstrumentBase.__init__ introspection under uv run (read-only). Allowed once. -- 17:54 Permission: coder asked inspect.getsource(InstrumentBase.__init__) (read-only introspection). Allowed once. -- 17:55 Permission: coder asked dir(InstrumentBase) for broadcast-named attrs (read-only introspection). Allowed once. -- 18:08 Permission: coder asked to introspect its own new Broadcaster class (read-only). Allowed once. -- 18:08 Permission: coder asked another read-only introspection of instrumentserver.base. Allowed once. -- 18:10 Permission: coder asked ruff + mypy over its changed files (read-only lint). Allowed once. -- Coder worker_done (succeeded). Commit 8d04b42 "0.2: add Broadcaster mixin and mix it into ParameterManager"; files: src/instrumentserver/base.py, src/instrumentserver/params.py, test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit, nothing dirty outside orchestration/ and the plan. Coder retained. -- Coder reported: unquoted class annotations on the mixin's public methods broke client proxy construction (the client execs the blueprint's call-signature string), so the public annotations are quoted; docstring notes the pattern for Phase 1. Judgement calls flagged: remove of an unregistered sink is a silent no-op; duplicate add is not deduped. Left out of scope: the client-side exec fragility itself. -- Orchestrator tests: `uv run pytest -q test/pytest/test_broadcaster.py` -> 8 passed in 0.01s; `uv run pytest -q` -> 169 passed, 4 warnings in 59.24s. -- 18:13 Six reviewers dispatched for round 0 (target a1bce5e..8d04b42). -- 18:14 Permission: reviewer-deepseek asked access to ~/.agents/roles (outside repo, wrong path). REJECTED; told it the role file is at .agents/roles/reviewer.md in the worktree. -- 18:14 Permission: plan-checker-deepseek asked git log + git show --stat (read-only). Allowed once. -- 18:14 Permission: reviewer-qwen asked a python heredoc introspecting bluePrintFromMethod on ParameterManager (read-only). Allowed once. -- 18:15 Permission: test-reviewer-qwen asked python -c introspection of Broadcaster/ParameterManager (read-only). Allowed once. -- 18:15 Permission: reviewer-qwen re-ran the blueprint introspection with a tempfile working dir (read-only apart from temp files). Allowed once. -- 18:15 Permission: plan-checker-qwen asked python heredoc introspecting ParameterBroadcastBluePrint (read-only). Allowed once. -- 18:16 Permission: plan-checker-qwen asked another read-only python introspection (qcodes). Allowed once. -- 18:16 Permission: reviewer-deepseek asked ls of orchestration/0.2 (read-only). Allowed once. -- 18:17 Permission: test-reviewer-qwen asked ls of orchestration/0.2 (read-only). Allowed once. -- 18:17 Permission: plan-checker-deepseek asked inspect.signature(InstrumentBase.__init__) (read-only). Allowed once. -- 18:17 reviewer-deepseek worker_done (succeeded, approve, 1 nit). Retained. -- 18:17 test-reviewer-deepseek worker_done (succeeded, approve, 0 findings). Retained. -- 18:17 test-reviewer-qwen worker_done (succeeded, changes-needed, 1 should-fix: no test pins duplicate-add / remove-first semantics). Retained. -- 18:18 Permission: reviewer-qwen asked a python heredoc simulating unquoted annotations to verify the coder's claim (read-only, in-memory). Allowed once. -- 18:18 plan-checker-qwen worker_done (succeeded, approve, 0 findings). Retained. -- 18:18 Permission: reviewer-qwen asked ls + grep for broadcast calls in params.py (read-only). Allowed once. -- 18:19 reviewer-qwen worker_done (succeeded, approve, 2 nits; notes for 0.3 that pm.broadcast becomes wire-callable). Retained. -- 18:29 plan-checker-deepseek: turn ended on a provider 'Upstream error' with no report after ~10 min (liveness live). Nudged in its terminal to resume and report. -- 18:37 Permission: plan-checker-deepseek asked wc/rg over its own report to check for garbled text (read-only). Allowed once. -- 18:38 plan-checker-deepseek worker_done after nudge (succeeded, approve, 0 findings). Retained. All six round-0 reports present. - -## Round 0 merge (six reports: 5 approve, test-reviewer-qwen changes-needed) - -- test-reviewer-qwen F1 (should-fix): no test pins the documented "same sink twice -> delivered twice; remove drops one" semantics that 0.3 relies on. One model only; orchestrator read test_broadcaster.py: confirmed, no test adds a sink twice. KEPT (cheap, documented behaviour without a test). -- reviewer-deepseek F1 (nit): `_broadcast_sinks` annotation in `__init__` is unquoted while the docstring asks to quote blueprint-carrying annotations; `__init__` is never proxied. Not sent: nit. -- reviewer-qwen N1 (nit): no callable check in add_broadcast_sink. Not sent: nit (misuse only). -- reviewer-qwen N2 (nit): exception log line dereferences bp.name/bp.action; could raise on a non-blueprint argument. Not sent: nit (contract violation path). -- reviewer-qwen note for 0.3: `pm.broadcast` becomes wire-callable once the Server registers as a sink, so a client could inject blueprints. Not a 0.2 finding; recorded here for whoever runs 0.3 and for the user. -- Fix list: 1 item -> fix round 1. -- 18:38 Fix round 1 dispatched to the coder in its same terminal (task_411a6077e1b1 / ctx_d0532410f0b2). -- 18:40 Permission: coder asked ruff + git add test_broadcaster.py + git commit '0.2: fix from review round 1: ...' + git log, chained (all coder-allowed operations). Allowed once. -- 18:42 Coder worker_done for fix round 1 (succeeded). Commit 693d4e7 "0.2: fix from review round 1: pin duplicate-sink delivery semantics in a unit test"; only test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files. Coder retained. -- Orchestrator tests after fix 1: named file -> 9 passed in 0.01s; full suite -> ================== 170 passed, 4 warnings in 60.07s (0:01:00) ================== -- 18:42 Re-review 1 dispatched to all six reviewers in their same terminals (target 693d4e7): - -| agent id | round | dispatch (task) | -|---|---|---| -| plan-checker-qwen | re-review 1 | ctx_2f5ef64a8d64 (task_f821ca7fbd80) | -| plan-checker-deepseek | re-review 1 | ctx_c79921dd0589 (task_91bcb85c373c) | -| test-reviewer-qwen | re-review 1 | ctx_43c777c6967b (task_f8267444d33c) | -| test-reviewer-deepseek | re-review 1 | ctx_de8f62675832 (task_22215d5bf2a1) | -| reviewer-qwen | re-review 1 | ctx_c5986422eae8 (task_2be451954633) | -| reviewer-deepseek | re-review 1 | ctx_2cc507da09ef (task_c47097cb4928) | - -- 18:43 Permission: plan-checker-deepseek asked git log + git show 693d4e7 (read-only). Allowed once. -- 18:43 Re-review 1: test-reviewer-qwen approve (F1 fixed, 0 new); reviewer-qwen approve (0 new); plan-checker-qwen approve (0 new). All three retained. -- 18:43 Permission: reviewer-deepseek asked ls of orchestration/0.2 (read-only). Allowed once. -- 18:44 Permission: plan-checker-deepseek asked ls of orchestration/0.2 (read-only). Allowed once. -- 18:44 Re-review 1: test-reviewer-deepseek approve (0 new). Retained. -- 18:45 Permission: plan-checker-deepseek asked to write its report via a python3 heredoc whose target path was cut off in the prompt. REJECTED; told it to use the file-write tool on orchestration/0.2/round-1/plan-checker-deepseek.md. -- 18:46 Permission: reviewer-deepseek asked access to a garbled non-existent path outside the repo. REJECTED; told it its report exists and to send worker_done. -- 18:46 Re-review 1: reviewer-deepseek approve (0 new). Retained. - -## Round 1 merge (six re-reviews, all `approve`, 0 new findings) - -- test-reviewer-qwen F1: fixed by 693d4e7 (confirmed by test-reviewer-qwen and test-reviewer-deepseek; both say the test would fail under dedup or remove-all semantics). -- reviewer-deepseek F1, reviewer-qwen N1/N2: dropped by orchestrator (nits), reviewers acknowledge and agree. -- Fix list: EMPTY. Task goes to finish. - -## Finish - -- All seven workers released (Orca kept the externally created terminals: state retained, processAction none) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_da269441b6ac: 0 rows. -- Checkbox 0.2 set to [x]. - -**Summary.** Outcome: done. Commits: `8d04b42 0.2: add Broadcaster mixin and mix it into ParameterManager`, `693d4e7 0.2: fix from review round 1: pin duplicate-sink delivery semantics in a unit test`. Fix rounds used: 1. Tests (orchestrator run after fix 1): `uv run pytest -q test/pytest/test_broadcaster.py` -> 9 passed in 0.01s; `uv run pytest -q` -> 170 passed, 4 warnings in 60.07s. diff --git a/orchestration/0.2/round-0/fix-list.md b/orchestration/0.2/round-0/fix-list.md deleted file mode 100644 index 259ec6d..0000000 --- a/orchestration/0.2/round-0/fix-list.md +++ /dev/null @@ -1,3 +0,0 @@ -# 0.2 — fix list after round 0 - -1. Add a unit test pinning the documented duplicate-sink semantics. Where: test/pytest/test_broadcaster.py, next to the sink registration tests. Why: the `Broadcaster` docstring (src/instrumentserver/base.py) says "adding the same sink twice makes it receive every Broadcast twice", the coder flagged no-deduplication as a judgement call, and task 0.3 builds the Server's sink registration on this contract, but no test covers it. Suggested: `test_adding_the_same_sink_twice_delivers_twice`: one `Broadcaster`, add the same sink twice, one `broadcast` -> sink called twice; one `remove_broadcast_sink` -> a further `broadcast` is delivered once. Raised by: test-reviewer-qwen F1 (should-fix); confirmed by the orchestrator by reading test_broadcaster.py (no test adds a sink twice). diff --git a/orchestration/0.2/round-0/plan-checker-deepseek.md b/orchestration/0.2/round-0/plan-checker-deepseek.md deleted file mode 100644 index 5552e0d..0000000 --- a/orchestration/0.2/round-0/plan-checker-deepseek.md +++ /dev/null @@ -1,76 +0,0 @@ -# 0.2 — plan-checker-deepseek — round 0 - -Verdict: approve - -The commit `8d04b42` implements exactly task 0.2, meets its acceptance criteria point by -point, and stays in scope (no trace of task 0.3's server-side sink registration). No -must-fix or should-fix findings. - -## Findings - -None. - -## Notes - -### Acceptance check against the plan task line - -Plan task 0.2 (PLAN_parameter_manager_redesign.md:415-419): - -> **0.2 `Broadcaster` mixin.** In `base.py` (next to `sendBroadcast`): class -> `Broadcaster` with `add_broadcast_sink(fn)`, `remove_broadcast_sink(fn)`, -> `broadcast(bp: ParameterBroadcastBluePrint)`; sinks stored in a list; exceptions in one -> sink are logged and do not stop the others; no sinks → no-op. `ParameterManager` inherits -> it (no emissions yet). Tests: `test_broadcaster.py` unit part. - -- **next to `sendBroadcast`**: `Broadcaster` is defined at base.py:75, immediately after - `sendBroadcast` (base.py:62-72). OK. -- **`add_broadcast_sink(fn)` / `remove_broadcast_sink(fn)` / `broadcast(bp)`**: all three - present at base.py:102, base.py:112, base.py:123. -- **sinks stored in a list**: `self._broadcast_sinks: list[...] = []` at base.py:98. -- **exceptions in one sink are logged and do not stop the others**: `broadcast` wraps each - sink call in `try/except Exception` with `logger.exception()`, iterating over a copy - (`list(self._broadcast_sinks)`) so a sink removing itself during broadcast does not - corrupt the loop. -- **no sinks → no-op**: the for-loop over the empty list does nothing. -- **`ParameterManager` inherits it (no emissions yet)**: `class ParameterManager(Broadcaster, - ParameterGroup)` at params.py:209; no `broadcast` call sites were added. -- **Tests: `test_broadcaster.py` unit part**: new file test/pytest/test_broadcaster.py, 8 - unit tests; none needs a Server. - -### Vocabulary (plan rule 2) - -Names used — `Broadcaster`, `Broadcast`, `sink`, `Station`, `Parameter Manager` — match the -glossary entries in CONTEXT.md (Broadcaster, Broadcast). "Sink" is the established word in -ADR-0003 ("registers its own broadcast function as a sink") and in the plan task text. The -"instrument mutex" term from D2 / ADR-0003 is referenced correctly (the Server registers -itself as a sink) and nothing confuses it with a Lock. Casing follows plan rule 8: new -methods `add_broadcast_sink`, `remove_broadcast_sink`, `broadcast` are `snake_case`; the new -class `Broadcaster` is `CamelCase`. - -### Judgement calls flagged by the coder - -- **Removing an unregistered sink is a silent no-op**: `remove_broadcast_sink` returns - without raising when `fn` is absent. The plan task text does not specify error behaviour, - and the docstring documents the choice ("Removing a sink that is not registered does - nothing"). Reasonable; not a defect. -- **Adding the same sink twice is not deduplicated**: matches the plan's "sinks stored in a - list" and is documented in the class docstring ("adding the same sink twice makes it - receive every Broadcast twice"). Not a defect. - -### Quoted annotations (orchestrator context) - -The public method annotations are quoted strings because the client builds proxy methods by -exec-ing the blueprint's call-signature string, which cannot resolve a rendered class -annotation. This is documented in the class docstring ("Keep new blueprint-carrying -annotations quoted like these."). It does not change the runtime signatures of the mixin, -so it does not violate the contract's method names. Acceptable. - -### Scope - -The commit touches only src/instrumentserver/base.py, src/instrumentserver/params.py, and -test/pytest/test_broadcaster.py. Nothing from task 0.3 (server registers sinks) is present. -Plan rule 6 (do not widen scope) is respected. - -### Tests run - -`uv run pytest -q test/pytest/test_broadcaster.py` -> 8 passed in 0.01s. \ No newline at end of file diff --git a/orchestration/0.2/round-0/plan-checker-qwen.md b/orchestration/0.2/round-0/plan-checker-qwen.md deleted file mode 100644 index 1b19211..0000000 --- a/orchestration/0.2/round-0/plan-checker-qwen.md +++ /dev/null @@ -1,60 +0,0 @@ -# 0.2 — plan-checker-qwen — round 0 - -Verdict: approve - -## Findings - -None. - -## Notes - -- Acceptance, point by point, against the task line "**0.2 `Broadcaster` mixin.** In `base.py` - (next to `sendBroadcast`): class `Broadcaster` with `add_broadcast_sink(fn)`, - `remove_broadcast_sink(fn)`, `broadcast(bp: ParameterBroadcastBluePrint)`; sinks stored in - a list; exceptions in one sink are logged and do not stop the others; no sinks → no-op. - `ParameterManager` inherits it (no emissions yet). Tests: `test_broadcaster.py` unit part.": - - Placed in `src/instrumentserver/base.py:75`, directly after `sendBroadcast` (lines 62–72) ✓ - - All three methods present with the plan's names ✓ - - Sinks stored in a list (`_broadcast_sinks`, base.py:98–100) ✓ - - Per-sink try/except with `logger.exception`; iteration over a snapshot - (`list(self._broadcast_sinks)`) so a sink removing itself mid-broadcast cannot break the - others (base.py:131–139) ✓ - - No sinks → loop over empty list → no-op ✓ - - `class ParameterManager(Broadcaster, ParameterGroup)` (params.py:209); no - `self.broadcast(...)` call anywhere in `params.py`, matching "(no emissions yet)" ✓ - - `test/pytest/test_broadcaster.py` contains only the unit part (no server fixture); the - file's docstring defers the server part, which task 0.3 owns. Eight tests cover: no-op - without sinks, sink receipt, registration order, exception-logged-and-others-run - (asserts exactly one ERROR record, the sink's name in the message, and `exc_info`), - removal, removing an unregistered sink, and the mixin on a real `ParameterManager` ✓ -- Quoted annotations. The plan's literal signature is `broadcast(bp: - ParameterBroadcastBluePrint)`; the commit quotes all public-method annotations. I verified - empirically that this was necessary: `str(inspect.signature(...))` of an unquoted class - annotation renders `instrumentserver.blueprints.ParameterBroadcastBluePrint`, and - `client/proxy.py:_makeProxyMethod` (lines 355–382) execs that string with a restricted - globals dict — unquoted fails with `NameError: name 'instrumentserver' is not defined`, - quoted execs clean. This matches the plan's own fact that "any new public method on - `ParameterManager` is callable from clients with no client changes" and would otherwise - have broken rule 7 ("Do not break the existing API"). The deviation is documented in the - class docstring (base.py:90–93) and in `orchestration/0.2/decisions.md`. Not a defect. -- Judgement calls flagged by the coder, both acceptable and documented: - - Removing an unregistered sink is a silent no-op — stated in the `remove_broadcast_sink` - docstring and covered by `test_removing_a_sink_that_was_never_added_is_a_noop`. The plan - does not prescribe the opposite. - - Duplicate `add_broadcast_sink` is not deduplicated — stated in the class docstring; - consistent with "Sinks are stored in a plain list". -- Plan rules: glossary vocabulary throughout docstrings, log message and test docstring - ("Broadcast", "sink", "Server", "Station", "client", "blueprint"; no avoided terms such as - "notification"/"hook"/"callback"). Casing per rule 8 (`Broadcaster`, `snake_case` - methods). Commit message starts with `0.2:`. No CONTEXT.md change needed: "Broadcaster" is - already a glossary term (CONTEXT.md:26) and no new term was introduced. -- Scope: diff touches only `src/instrumentserver/base.py` (imports + mixin), - `src/instrumentserver/params.py` (import, base class, docstring), and the new test file. - No work from 0.3/0.4/0.5 leaked in. -- ADR-0003 consistent: opt-in contract, standalone use is a no-op, `ParameterBroadcastBluePrint` - as the payload type, and server-side registration explicitly left to task 0.3 - (`hasattr(instrument, "add_broadcast_sink")`). -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in 0.01s`. - `uv run pytest -q` (full suite) → `169 passed, 4 warnings in 58.54s` (same count and the - same pre-existing warnings as the orchestrator's run; the unknown-mark warning comes from - `test/pytest/test_apps.py`, not this commit). diff --git a/orchestration/0.2/round-0/reviewer-deepseek.md b/orchestration/0.2/round-0/reviewer-deepseek.md deleted file mode 100644 index ed0d30a..0000000 --- a/orchestration/0.2/round-0/reviewer-deepseek.md +++ /dev/null @@ -1,53 +0,0 @@ -# 0.2 — reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -No findings at must-fix or should-fix severity. - -### F1 — nit -- Where: src/instrumentserver/base.py:98-100 -- What: `__init__`'s `_broadcast_sinks` annotation is an unquoted - `Callable[[ParameterBroadcastBluePrint], None]`, while the class docstring - (lines 90-93) instructs that blueprint-carrying annotations be quoted - because the client proxy execs the call-signature string. -- Why: Not a bug — `__init__` is never a proxied method (only public - `add_broadcast_sink` / `remove_broadcast_sink` / `broadcast` appear in the - blueprint, and all three are correctly quoted), so no exec path can trip on - it. It is only inconsistent with the stated convention. -- Suggested fix: Either quote it for consistency, or note in the docstring - that the quoting rule applies to proxied (blueprint-listed) methods only. - No behavioural change either way. - -## Notes - -- Commit: 8d04b42 `0.2: add Broadcaster mixin and mix it into ParameterManager` - (the only commit in `a1bce5e..8d04b42`). -- Verified in my lane: - - `broadcast` iterates `list(self._broadcast_sinks)` (a copy), so a sink - registering/removing sinks mid-broadcast cannot raise - `RuntimeError: list changed during iteration`; the plan's "exceptions in - one sink are logged and do not stop the others" is satisfied and the - remaining sinks still run. - - `remove_broadcast_sink` uses `in` + `list.remove`, so removing an - unregistered sink is a silent no-op and removing one of two equal sinks - deletes only the first occurrence — consistent with the class docstring's - documented no-dedup behaviour (the coder's flagged judgement calls). Not a - defect against the plan, which only says "sinks stored in a list". - - MRO check: `ParameterManager(Broadcaster, ParameterGroup)` — `__init__` - at params.py:233 calls `super().__init__(name)` which lands in - `Broadcaster.__init__(*args, **kwargs)`, initialising `_broadcast_sinks` - then forwarding to `ParameterGroup.__init__`. A real `ParameterManager` - constructs correctly (proven by the passing test). Submodules stay plain - `ParameterGroup` (no Broadcaster), matching D15. - - The quoted-annotation claim is sound: proxy.py:376-382 builds and `exec`s - the method source with `globs` = `wrap/qcodes/collections` plus - `from typing import *`; an unquoted class annotation would render a name - (`ParameterBroadcastBluePrint`) that the exec'd source cannot resolve. - - New import `from collections.abc import Callable` and - `ParameterBroadcastBluePrint` in base.py do not shadow or clash with - existing names; no caller breakage found (no existing API removed or - changed). -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → 8 passed. - Did not run the full suite (no need for this review; unit part green). \ No newline at end of file diff --git a/orchestration/0.2/round-0/reviewer-qwen.md b/orchestration/0.2/round-0/reviewer-qwen.md deleted file mode 100644 index ab161a3..0000000 --- a/orchestration/0.2/round-0/reviewer-qwen.md +++ /dev/null @@ -1,79 +0,0 @@ -# 0.2 — reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -### N1 — nit -- Where: src/instrumentserver/base.py:102-110 -- What: `add_broadcast_sink` does not check that `fn` is callable; a non-callable - "sink" stays registered and raises `TypeError` on every subsequent - `broadcast`, which is then swallowed and logged as a sink exception. -- Why: The contract types the argument as a callable, so this only matters on - misuse, but the failure mode is a permanently failing sink that hides a - registration-time mistake behind per-Broadcast log noise. -- Suggested fix: One-line fail-fast at registration: `if not callable(fn): raise - TypeError(...)`. Optional; behaviour otherwise matches the plan's contract. - -### N2 — nit -- Where: src/instrumentserver/base.py:134-138 -- What: The exception log line dereferences `bp.name` / `bp.action` while - handling a sink exception. -- Why: If a caller passes a non-`ParameterBroadcastBluePrint` (contract - violation) and a sink raises, the f-string itself raises `AttributeError`, - which escapes `broadcast` instead of being logged. Extremely narrow edge of - an already-invalid call, so preference only. -- Suggested fix: Log via `getattr(bp, "name", bp)` / `getattr(bp, "action", - bp)` or just `bp!r`, so the logging path cannot raise. - -## Notes - -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in - 0.01s`; full suite `uv run pytest -q` → `169 passed, 4 warnings in 58.30s` - (same result the orchestrator recorded in decisions.md). -- Plan conformance of the shape: all three method names and the - `broadcast(bp: ParameterBroadcastBluePrint)` signature match the task; sinks - are a list; no sinks → no-op; one sink's exception is logged (ERROR with - traceback, which satisfies "logged") and does not stop the others - (`test_exception_in_one_sink_is_logged_and_others_still_run` verifies - order, level, and that later sinks still receive); `ParameterManager` - inherits the mixin (MRO `ParameterManager → Broadcaster → ParameterGroup → - InstrumentBase`, valid) and emits nothing yet (no `self.broadcast` calls in - `params.py`). The class sits in `base.py` directly after `sendBroadcast`, - as the plan says. -- The coder's reported judgement call about quoted annotations is correct and - empirically verified, not a style choice: I reproduced the client pipeline. - `bluePrintFromInstrumentModule` (`blueprints.py:334-344`) picks up all three - inherited public methods via `dir(ins)`, and `str(inspect.signature(...))` - of an *unquoted* annotation renders the dotted qualified name - (`instrumentserver.blueprints.ParameterBroadcastBluePrint`), which the - client's `exec` in `_makeProxyMethod` (`client/proxy.py:376-382`, globs - limited to `wrap`/`qcodes`/`collections` + `typing`) cannot resolve → - `NameError` during proxy construction, i.e. every client that builds a - ParameterManager proxy would break. Quoted annotations render as string - literals and exec cleanly (verified for all three methods). The class - docstring paragraph documenting this for Phase 1 is a useful guardrail. -- The two flagged judgement calls (remove of an unregistered sink is a silent - no-op; duplicate add is not deduplicated) are consistent with the plan, - documented in the docstring and the `remove` docstring respectively, and - covered by tests. I agree with both; one note on coherence: with duplicates, - one `remove_broadcast_sink` removes one registration, which matches the - "added twice → receives twice" semantics. -- Observation for task 0.3 (not a defect of this commit): once the Server - registers itself as a sink, `pm.broadcast` is callable from any remote - client (the plan, line 158, makes every new public method proxyable, and - call args are JSON-decoded with `deserialize_obj`, so a dict carrying - `_class_type` becomes a real `ParameterBroadcastBluePrint`). A client could - then inject arbitrary blueprints into the PUB broadcast stream. ` - add_broadcast_sink` is effectively not operable over the wire (a callable - is not JSON-serialisable), so the exposure is `broadcast` only. This is - inherent to the plan's "public methods are proxyable" design; worth a - sentence in the 0.3 review or the plan checker's lane. -- No import-cycle risk introduced: `params.py → base.py → blueprints.py` and - `base.py → blueprints.py` already existed in the parent commit; full suite - green confirms. -- Commit hygiene: exactly one commit `8d04b42` in the range, message prefixed - `0.2:`, only the three expected files touched; no test is vacuous — each - would fail if its behaviour regressed (no-op without sinks, delivery, - registration order, exception isolation + logging, removal, unregistered - removal, mixin inheritance on a real `ParameterManager`). diff --git a/orchestration/0.2/round-0/test-reviewer-deepseek.md b/orchestration/0.2/round-0/test-reviewer-deepseek.md deleted file mode 100644 index 1439da3..0000000 --- a/orchestration/0.2/round-0/test-reviewer-deepseek.md +++ /dev/null @@ -1,45 +0,0 @@ -# 0.2 — test-reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -No findings. The task's named test file is present, all tests are unit tests at the -correct layer (no server), and each required behaviour of the `Broadcaster` mixin is -covered by a test that would genuinely fail if the behaviour were broken. - -Per-test mapping: - -- `test_broadcast_without_sinks_is_a_noop` — proves `broadcast` with no sinks neither - raises nor needs sinks (plan: "no sinks → no-op"). Would fail if it raised. -- `test_broadcast_reaches_the_added_sink` — proves the blueprint object is delivered to a - removed-sink-after-add sink intact (identity check). Would fail if the sink received - nothing or a mutated value. -- `test_broadcast_reaches_all_sinks_in_registration_order` — proves fan-out to all sinks in - registration order (plan: "sinks stored in a list"; order corresponds to list order). - Would fail if a sink were skipped or order scrambled. -- `test_exception_in_one_sink_is_logged_and_others_still_run` — proves a raising sink is - logged (`caplog`, ERROR level, exc_info set, sink name in the message) and does not stop - the remaining sink (plan: "exceptions in one sink are logged and do not stop the others"). - Would fail if the exception propagated or the second sink were skipped. -- `test_removed_sink_no_longer_receives_broadcasts` — proves `remove_broadcast_sink` - detaches a sink. Would fail if removal did nothing. -- `test_removing_a_sink_that_was_never_added_is_a_noop` — covers the coder's flagged - judgement call (silent no-op on removing an unregistered sink); asserts no raise. Would - fail if removing an unknown sink raised. -- `test_parameter_manager_is_a_broadcaster` — proves `ParameterManager` inherits the mixin - and exposes all three public methods. Would fail if the class did not mix it in. -- `test_parameter_manager_broadcast_reaches_sink` — proves the mixin machinery works on a - real `ParameterManager` (add, broadcast, remove), exercising the `Broadcaster.__init__` - chained-`super().__init__` path. Would fail if the mixin init broke ParameterManager - construction or the sink were not called. - -## Notes - -- Commit range `a1bce5e..8d04b42` is exactly one commit (8d04b42). -- No existing tests weakened, deleted or skipped. No parametrised-away assertions. -- Behaviour added: quoted annotations on public methods (reason flagged by the coder) and - the judged no-op/dedup points. The no-op-remove is tested; duplicate-add-not-deduped is - documented in the docstring and is not a required behaviour in the plan, so no test is - mandated for it. -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in 0.01s`. \ No newline at end of file diff --git a/orchestration/0.2/round-0/test-reviewer-qwen.md b/orchestration/0.2/round-0/test-reviewer-qwen.md deleted file mode 100644 index d40f61b..0000000 --- a/orchestration/0.2/round-0/test-reviewer-qwen.md +++ /dev/null @@ -1,20 +0,0 @@ -# 0.2 — test-reviewer-qwen — round 0 - -Verdict: changes-needed - -## Findings - -### F1 — should-fix -- Where: test/pytest/test_broadcaster.py (gap; belongs next to the sink registration tests, lines ~27–58) -- What: The commit documents, in the `Broadcaster` class docstring, that "adding the same sink twice makes it receive every Broadcast twice", and the coder flagged no-deduplication as an explicit judgement call, but no test pins this behaviour down. -- Why: The plan says "sinks stored in a list" (task 0.2), and task 0.3 makes the Server register itself as a sink per instrument — a double registration would silently double every wire broadcast to all GUIs and Listeners. Without a test, a future "fix" that deduplicates (or a change to `remove_broadcast_sink`, which removes only the first matching entry) would pass CI while changing the contract the next task builds on. -- Suggested fix: Add `test_adding_the_same_sink_twice_delivers_twice`: setup — one `Broadcaster`, add the same sink (e.g. `received.append`) twice; action — one `broadcast(make_bp())`; expected — the sink receives the blueprint twice (`len(received) == 2`), and one `remove_broadcast_sink` call leaves one active sink (a second broadcast is still delivered once). - -## Notes - -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `8 passed in 0.01s`. Full suite: `uv run pytest -q` → `169 passed, 4 warnings in 58.55s`. -- Per-test check (all 8 tests in the new file): each would fail if the feature were broken — no-sink no-op fails if empty fan-out raises; single-sink/reach tests fail if `broadcast` skips sinks; ordering test fails if fan-out order changes; the exception test fails both if the exception is not logged (asserts exactly one ERROR record on `instrumentserver.base` with `exc_info` set, and that the message names the failing sink) and if it stops the remaining sinks (`received == [bp]`); removal tests fail if removal is a no-op. `test_parameter_manager_is_a_broadcaster` / `test_parameter_manager_broadcast_reaches_sink` fail if the mixin is not mixed into `ParameterManager` (also exercises the `Broadcaster.__init__` → `ParameterGroup.__init__` MRO chain on a real construction). -- Layer and naming: all tests are server-free unit tests, matching the task's "unit part" of `test_broadcaster.py` (the server part is task 0.3). Names use the plan/CONTEXT vocabulary (Broadcast, sink, no-op, Broadcaster); the module docstring correctly defers the server part to a separate task. -- Quoted-annotation fix is covered indirectly: the three new public methods appear in `dir(ParameterManager)` and are not shadowed by the qcodes base class, so `bluePrintFromInstrumentModule` includes them and every test that builds a Parameter Manager proxy (`param_manager` fixture in `test_param_manager.py`, `test_basic_functionality.py`) execs the new signature strings in `_makeProxyMethod`. I verified `str(inspect.signature(...))` renders the quoted annotations as string literals, which the client's `exec` accepts; the full-suite pass confirms it. An explicit proxy-level test for `broadcast` lands with task 0.3, as planned. -- Considered, not findings: sink removed mid-broadcast (the implementation iterates a copy of the list; the plan does not require this edge) and passing a wrong-typed blueprint to `broadcast` (plan does not require it). "No emissions yet" on `ParameterManager` is a scope rule, not a named test — left to the plan checker. -- No pre-existing tests were weakened, deleted or skipped by this commit; it adds one new test file only. diff --git a/orchestration/0.2/round-1/fix-list.md b/orchestration/0.2/round-1/fix-list.md deleted file mode 100644 index 14409c7..0000000 --- a/orchestration/0.2/round-1/fix-list.md +++ /dev/null @@ -1,3 +0,0 @@ -# 0.2 — fix list after round 1 - -Empty. All six reviewers returned `approve` on fix commit 693d4e7; the one round-0 should-fix (test-reviewer-qwen F1) is marked fixed, no new findings. diff --git a/orchestration/0.2/round-1/plan-checker-deepseek.md b/orchestration/0.2/round-1/plan-checker-deepseek.md deleted file mode 100644 index fb53dfe..0000000 --- a/orchestration/0.2/round-1/plan-checker-deepseek.md +++ /dev/null @@ -1,28 +0,0 @@ -# 0.2 - plan-checker-deepseek - round 1 - -Verdict: approve - -The fix commit 693d4e7 adds exactly one unit test pinning the documented -duplicate-sink semantics. It changes no src code, stays in scope, and does -not break or weaken anything in my focus area. - -## Previous findings - -My round-0 report had no findings (verdict approve). There is nothing of -mine to mark as fixed, not fixed, or dropped. The single fix-list item was -raised by test-reviewer-qwen, not by me. - -## New findings from the fix - -None. The added test is correct, matches the class docstring, and can fail -if the documented semantics were ever changed. - -## Notes - -The test asserts that adding the same sink twice delivers two copies, and -that a single remove drops one occurrence, leaving one delivery. That is -exactly what the docstring promises and what task 0.3 relies on. - -Vocabulary and casing follow the glossary and plan rule 8. - -Run: uv run pytest -q test/pytest/test_broadcaster.py -> 9 passed in 0.01s. \ No newline at end of file diff --git a/orchestration/0.2/round-1/plan-checker-qwen.md b/orchestration/0.2/round-1/plan-checker-qwen.md deleted file mode 100644 index 5d1fe3c..0000000 --- a/orchestration/0.2/round-1/plan-checker-qwen.md +++ /dev/null @@ -1,43 +0,0 @@ -# 0.2 — plan-checker-qwen — round 1 - -Verdict: approve - -## Previous findings - -My round-0 report had **zero findings** (verdict: approve, 0 must-fix / 0 should-fix / 0 nit), so there -is nothing to mark fixed / not fixed / dropped. The single fix-list item -(`orchestration/0.2/round-0/fix-list.md`, item 1) was raised by test-reviewer-qwen, not by me; it is -implemented by the new test `test_adding_the_same_sink_twice_delivers_twice`. - -## Findings - -None. - -## Fix commit check (693d4e7) - -- Scope: adds exactly one test to `test/pytest/test_broadcaster.py`; no `src/` change, no other file. - Nothing from task 0.3 or later leaked in. -- Plan consistency: pins the behaviour the round-0 docstring already committed to - (`src/instrumentserver/base.py`: "adding the same sink twice makes it receive every Broadcast - twice") and plan task 0.2's "sinks stored in a list": add same sink twice → one `broadcast` - delivers twice (`assert received == [bp, bp]`), one `remove_broadcast_sink` → one remaining - registration (`assert received == [bp, bp, bp]` after a further broadcast). Matches - `list.append` / first-occurrence `list.remove` semantics exactly. -- Test quality: it can fail — a deduplicating store would fail the first assertion; removing all - occurrences would fail the second. Not a vacuous test. -- Vocabulary: test name and docstring use "sink" / "Broadcast" only; no glossary violations. -- Plan rules: test lives in the plan-named file `test_broadcaster.py`; commit message starts with - the task number (`0.2: fix from review round 1: ...`), per session protocol step 6 - ("each round of review fixes is its own commit"). -- No weakening: the fix is purely additive; round-0 behaviour (mixin semantics, no emissions, - quoted annotations) is untouched. - -## Notes - -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s` - (was 8 in round 0; +1 from the fix commit). Full-suite run not needed this round; the - orchestrator's post-fix run (`decisions.md` log, 18:42) recorded 170 passed, 4 pre-existing - warnings. -- The round-0 notes in my previous report (quoted-annotation deviation justified and verified; - both flagged judgement calls documented) still stand; this round pins the duplicate-sink - judgement call in a test as well. diff --git a/orchestration/0.2/round-1/reviewer-deepseek.md b/orchestration/0.2/round-1/reviewer-deepseek.md deleted file mode 100644 index e073c33..0000000 --- a/orchestration/0.2/round-1/reviewer-deepseek.md +++ /dev/null @@ -1,42 +0,0 @@ -# 0.2 — reviewer-deepseek — round 1 (re-review) - -Verdict: approve - -## Previous findings - -- F1 (nit) — unquoted `_broadcast_sinks` annotation in `__init__` - (src/instrumentserver/base.py:98-100) inconsistent with the class - docstring's quoting convention. **Dropped by orchestrator** (decisions.md - line 56, "Not sent: nit"). Not fixed in 693d4e7, which touches only the - test file. I agree with the drop: `__init__` is never a proxied method, so - the inconsistency is cosmetic and not worth a fix round. - -No other findings in my round-0 report. - -## Regression check (did 693d4e7 break or weaken anything in my lane?) - -The fix commit adds a single unit test, -`test_adding_the_same_sink_twice_delivers_twice`, and touches no `src/` -file. There is no new production code path to regress. The new test pins the -already-documented duplicate-sink semantics and matches the existing -`Broadcaster` implementation exactly: `add_broadcast_sink` appends -unconditionally (no dedup), `broadcast` iterates `list(self._broadcast_sinks)` -(a copy), and `remove_broadcast_sink` uses `in` + `list.remove`, deleting the -first occurrence. The test's expectations (`[bp, bp]` after one broadcast of -a duplicated sink; `[bp, bp, bp]` after a single remove and a further -broadcast) are consistent with that code. The mixin's correctness, -exception-isolation and no-sink no-op behaviour are unweakened. - -## Findings - -No new findings at any severity. - -## Notes - -- The fix round was driven by test-reviewer-qwen's F1 (a test-coverage gap: no - test adds the same sink twice), which is squarely in the test reviewer's - lane, not mine. It does not introduce any general-review concern. -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → - 9 passed in 0.01s. I ran the named file only (a full-suite run is not - needed for this re-review; the orchestrator separately confirmed 170 - passed in full after the fix). \ No newline at end of file diff --git a/orchestration/0.2/round-1/reviewer-qwen.md b/orchestration/0.2/round-1/reviewer-qwen.md deleted file mode 100644 index ccfd451..0000000 --- a/orchestration/0.2/round-1/reviewer-qwen.md +++ /dev/null @@ -1,41 +0,0 @@ -# 0.2 — reviewer-qwen — round 1 - -Verdict: approve - -## Previous findings - -- N1 (nit) — no `callable` check in `add_broadcast_sink` (src/instrumentserver/base.py:102-110): **dropped by orchestrator.** Decisions log "Round 0 merge": "Not sent: nit (misuse only)." Not in the fix list; base.py is unchanged in 693d4e7. Accepted. -- N2 (nit) — exception log line dereferences `bp.name`/`bp.action` and could raise on a non-blueprint argument (src/instrumentserver/base.py:134-138): **dropped by orchestrator.** "Not sent: nit (contract violation path)." Not in the fix list; unchanged. Accepted. - -Both were nits; neither needed fixing for this task. No action expected. - -## Fix commit review (693d4e7) - -The commit adds exactly one test, `test_adding_the_same_sink_twice_delivers_twice` -(test/pytest/test_broadcaster.py:56-73), and nothing in src/ changes. - -- It implements the round-0 fix-list item (raised by test-reviewer-qwen) exactly as - suggested: one `Broadcaster`, the same sink added twice, one `broadcast` → sink - called twice (`received == [bp, bp]`), one `remove_broadcast_sink` → a further - `broadcast` delivered once (`received == [bp, bp, bp]`). -- It pins the semantics documented in the `Broadcaster` class docstring - ("adding the same sink twice makes it receive every Broadcast twice") that task 0.3 - builds on, and is not vacuous: a deduplicating implementation fails the first - assertion; a `remove` that drops all equal sinks fails the second. -- Correctness note: `received.append` is a bound method, and the two registered - references compare equal, so `list.remove` (used in `remove_broadcast_sink`, - base.py:119-121) removes exactly one registration — the test matches the - implementation's `in`/`remove` semantics. -- Placement is next to the other sink-registration tests, as the fix list asked; - name and docstring use glossary terms ("Broadcast", "sink"). - -## New findings - -None. The commit touches only the test file and adds a passing, non-vacuous test; -no behaviour in my focus area changed or weakened. - -## Notes - -- `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s` (was 8; - matches the orchestrator's post-fix run in decisions.md, full suite 170 passed). -- Counts: 0 must-fix, 0 should-fix, 0 new nits. diff --git a/orchestration/0.2/round-1/test-reviewer-deepseek.md b/orchestration/0.2/round-1/test-reviewer-deepseek.md deleted file mode 100644 index 639c352..0000000 --- a/orchestration/0.2/round-1/test-reviewer-deepseek.md +++ /dev/null @@ -1,37 +0,0 @@ -# 0.2 — test-reviewer-deepseek — round 1 - -Verdict: approve - -## Previous findings - -My round-0 report had **no findings**: it approved with 0 must-fix, 0 should-fix, 0 nit. -Nothing of mine was carried into `fix-list.md`. The single fix-list item (should-fix, raised -by test-reviewer-qwen, confirmed by the orchestrator) asked for a unit test pinning the -documented duplicate-sink semantics. That item is addressed by commit 693d4e7: - -- New test `test_adding_the_same_sink_twice_delivers_twice` — pins that adding the same - sink twice delivers each Broadcast twice, and a single remove leaves it registered once. - Status: **fixed** (the fix-list item, added by the coder; not my own finding but in scope - of this review's focus area). - -## Findings - -No findings. - -- The added test genuinely discriminates: if `add_broadcast_sink` ever deduplicated, the - `len(received) == 2` / `received == [bp, bp]` assertions on the first `broadcast` would - fail (would be 1 delivery). If `remove_broadcast_sink` removed every occurrence instead of - the first, the final `received == [bp, bp, bp]` would fail (would be `[bp, bp]`). -- Right layer: pure unit test, no server, consistent with the task's "test_broadcaster.py - unit part". -- Name and assertions use the plan's vocabulary ("sink", "Broadcast") and match the recommended - fix exactly. -- The commit touches only `test/pytest/test_broadcaster.py`; no src/ change, and no existing - test was modified, deleted, weakened, or skipped. - -## Notes - -- Fix commit 693d4e7 adds one test; the previous suite count was 8, now 9 tests. -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s` - (matches the orchestrator's full-suite line: 170 passed after this fix). -- New findings caused by the fix commit: none. \ No newline at end of file diff --git a/orchestration/0.2/round-1/test-reviewer-qwen.md b/orchestration/0.2/round-1/test-reviewer-qwen.md deleted file mode 100644 index 66b68c4..0000000 --- a/orchestration/0.2/round-1/test-reviewer-qwen.md +++ /dev/null @@ -1,18 +0,0 @@ -# 0.2 — test-reviewer-qwen — round 1 - -Verdict: approve - -## Previous findings - -- F1 (should-fix, round 0): no test pins the documented duplicate-sink semantics ("adding the same sink twice makes it receive every Broadcast twice"; `remove_broadcast_sink` drops only the first matching entry). - **Status: fixed.** Fix commit `693d4e7` adds `test_adding_the_same_sink_twice_delivers_twice` (test/pytest/test_broadcaster.py:56), placed next to the sink registration tests. Setup: one `Broadcaster`, the same sink (`received.append`) added twice; action: one `broadcast(bp)`; expected: `received == [bp, bp]`. Then one `remove_broadcast_sink` and a second broadcast; expected: `received == [bp, bp, bp]` (one remaining registration delivers once). This matches the setup/action/expected I proposed in round 0, and it would fail if `add_broadcast_sink` deduplicated, if removal cleared both registrations, or if removal was a no-op. No other findings were open; nothing was dropped by the orchestrator. - -## Findings - -None. (0 must-fix, 0 should-fix, 0 nit.) - -## Notes - -- Tests run: `uv run pytest -q test/pytest/test_broadcaster.py` → `9 passed in 0.01s`. Full-suite run not required for this re-review (orchestrator log records `170 passed, 4 warnings in 60.07s` after the fix). -- The fix commit is an insert-only change to test/pytest/test_broadcaster.py (verified with `git show 693d4e7`): one new test function added between `test_broadcast_reaches_all_sinks_in_registration_order` and `test_exception_in_one_sink_is_logged_and_others_still_run`; the other eight tests and all src/ files are untouched, so nothing in my focus area was weakened or broken. -- New test layer and naming: server-free unit test, in plan vocabulary (sink, Broadcast, deliver); the docstring states the pinned contract explicitly. No new behaviour or gap introduced. diff --git a/orchestration/0.3/decisions.md b/orchestration/0.3/decisions.md deleted file mode 100644 index 71e437c..0000000 --- a/orchestration/0.3/decisions.md +++ /dev/null @@ -1,88 +0,0 @@ -# 0.3 Server registers sinks — decisions log - -Run: run_e6f4c00ea2df. Branch: marcosfrenkel/new-param-manager. Base commit: 0fbbddf9ba0410fbbd429f5f348067726591d4d8. - -## Workers - -| agent id | terminal handle | current dispatch id | -|---|---|---| -| coder | term_fa8e9bba-840a-4c41-94dc-ffb92f711994 | ctx_fd0b239abbe7 (task_9e15d44fb49c, first implementation) | -| reviewer-deepseek | term_fd9f70ce-fbbf-41be-b89b-8dd0ef157538 | ctx_51040886c99c (task_2fe1fd041374, round 0) | -| reviewer-qwen | term_1e0050e0-7251-4873-b435-d115c441d817 | ctx_1f5916083bb7 (task_e13d8b591c4b, round 0) | -| test-reviewer-deepseek | term_1fcc956e-b7df-4afd-981e-fd72654c3ab8 | ctx_3c25a77d4dd6 (task_10986933eb7f, round 0) | -| test-reviewer-qwen | term_d25fed52-3ba6-4fa0-950a-5193ba409df8 | ctx_8e04c87e0f1b (task_8fb32080b1ba, round 0) | -| plan-checker-deepseek | term_1cf78444-2d21-466a-8fd1-60d4adc48409 | ctx_fd34c39a35ab (task_db33463445a0, round 0) | -| plan-checker-qwen | term_8f972e62-0347-47a2-a003-80701d5849fe | ctx_c741ac0934e3 (task_e3a0c52af6a1, round 0) | - -## Log - -- Checkbox 0.3 set to [~]. Base 0fbbddf. -- Carried over from run_da269441b6ac (reviewer-qwen note): once the Server registers as a sink, `ParameterManager.broadcast` becomes wire-callable, so a client could inject Broadcasts. Not part of the 0.3 task text; will be raised to the user at task end if reviewers do not raise it. -- Coder dispatched for first implementation (task_9e15d44fb49c / ctx_fd0b239abbe7). -- Coder worker_done (succeeded). Commit 04c4cbc "0.3: server registers itself as a broadcast sink on Broadcaster instruments"; files: src/instrumentserver/server/core.py, src/instrumentserver/testing/dummy_instruments/generic.py, test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files in commit, nothing dirty outside orchestration/ and the plan. No permission prompts. Coder retained. -- Coder flagged: the config-load registration path in `__init__` shares the helper but has no dedicated test (the task's Tests line names only the created-instrument case). -- Orchestrator tests: `uv run pytest -q test/pytest/test_broadcaster.py` -> 11 passed in 7.44s; `uv run pytest -q` -> 172 passed, 4 warnings in 66.58s. -- Six reviewers dispatched for round 0 (target 0fbbddf..04c4cbc). -- Permission: plan-checker-qwen asked ls of .venv site-packages + python -c import qcodes (read-only, in worktree). Allowed once. -- Permission: reviewer-qwen asked uv run python -c import qcodes version (read-only). Allowed once. -- plan-checker-qwen worker_done (succeeded, approve, 0 findings, 2 notes). Retained. -- test-reviewer-qwen worker_done (succeeded, changes-needed: 1 must-fix config-load path untested, 2 nits). Retained. -- Permission: reviewer-qwen asked rg pyproject + uv run ruff check on changed files (read-only lint). Allowed once. -- test-reviewer-deepseek worker_done (succeeded, changes-needed: 1 should-fix config-load path untested). Retained. -- Permission: reviewer-qwen asked uv run mypy on changed files (read-only). Allowed once. -- Permission: reviewer-deepseek asked ls orchestration/0.3/round-0 + git status --short (read-only). Allowed once. -- reviewer-qwen worker_done (succeeded, approve, 0 findings, 3 notes). Retained. -- Permission: plan-checker-deepseek's worker_done send was prefixed with cd && so it prompted (allowed command). Allowed once. -- plan-checker-deepseek worker_done (succeeded, approve, 1 nit). Retained. -- reviewer-deepseek: turn ended idle after 'Let me write my report' with no report and no worker_done (liveness live). Nudged in its terminal to write the report and send worker_done. -- reviewer-deepseek worker_done after nudge (succeeded, approve, 2 nits). Retained. All six round-0 reports present. - -## Round 0 merge (six reports: 4 approve, test-reviewer-qwen and test-reviewer-deepseek changes-needed) - -- test-reviewer-qwen F1 (must-fix) + test-reviewer-deepseek F1 (should-fix): the `__init__` config-load registration loop has no test although the plan's Testing table names "created and config-loaded instruments" for test_broadcaster.py. Both models of the same role raised it. KEPT. -- test-reviewer-qwen F2 (nit): "gets no sink" asserted via hasattr(add_broadcast_sink) rather than a server-side property. Not sent: nit. -- test-reviewer-qwen F3 (nit): emit_broadcast return value not asserted. Not sent: nit. -- reviewer-deepseek N1 (nit): registration in __init__ before broadcastSocket exists is harmless. Not sent: nit (no change requested). -- reviewer-deepseek N2 (nit): test peeks at private _broadcast_sinks. Not sent: nit; the reviewer itself says no change. -- plan-checker-deepseek N1 (nit): __init__ loop covers all station components, a superset of config-loaded ones. Not sent: nit; matches ADR-0003 intent. -- plan-checker-qwen nit: `_registerBroadcaster` is camelCase while rule 8 says new methods are snake_case; the task text names the helper explicitly. Not sent: the task's explicit name wins. -- Notes for the user (not findings): (a) plan-checker-qwen and reviewer-qwen: `_runInitScript` can add instruments to the Station after the `__init__` loop, and those get no sink; the plan lists only two entry points, so out of scope for 0.3. (b) reviewer-qwen: the mixin's public methods are wire-callable on any Broadcaster proxy; inert in practice (callables do not survive JSON; a bad `broadcast(dict)` hits the logged sink-error path); a 0.2 design consequence, not a 0.3 defect. -- Fix list: 1 item -> fix round 1. -- Fix round 1 dispatched to the coder in its same terminal (task_99cd8f9b7764 / ctx_d426a391c7af). -- Permission: coder asked a python heredoc temporarily mutating the __init__ registration loop in core.py to prove the new test fails (mutation check; coder may edit). Allowed once; orchestrator will verify the fix commit leaves the loop intact. -- Coder worker_done for fix round 1 (succeeded). Commit 5167241 "0.3: fix from review round 1: test the config-load sink registration path"; only test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files, core.py __init__ loop intact (mutation check restored). Coder retained. -- Coder skipped the optional SubClient emission part of item 1: a never-started StationServer has no bound PUB socket, so emission would trip the broadcastSocket assert; the wire path is covered by the created-instrument test. -- Orchestrator tests after fix 1: named file -> 12 passed in 7.49s; full suite -> 173 passed, 4 warnings in 67.55s. -- Re-review 1 dispatched to all six reviewers in their same terminals (target 5167241): - -| agent id | round | dispatch (task) | -|---|---|---| -| plan-checker-qwen | re-review 1 | ctx_19a2de38940f (task_246d52f110fc) | -| plan-checker-deepseek | re-review 1 | ctx_7e6b9fc81a79 (task_7ba08a2058de) | -| test-reviewer-qwen | re-review 1 | ctx_26e77fcef650 (task_a2db5871dc4f) | -| test-reviewer-deepseek | re-review 1 | ctx_a33692cd45cd (task_41eaedb25a3d) | -| reviewer-qwen | re-review 1 | ctx_7e5853512036 (task_502a4ba74afe) | -| reviewer-deepseek | re-review 1 | ctx_d5d03671868b (task_62ba56ee4d95) | - -- Permission: reviewer-qwen asked uv run pytest + ruff check (test run + read-only lint). Allowed once. -- Re-review 1: test-reviewer-qwen approve (F1 fixed, F2/F3 dropped, 0 new). Retained. -- Re-review 1: plan-checker-qwen approve (0 new). Retained. -- Re-review 1: reviewer-qwen approve (0 new). Retained. -- Re-review 1: reviewer-deepseek approve (0 new). Retained. -- Re-review 1: test-reviewer-deepseek stalled on a provider 'Upstream error' with no report; plan-checker-deepseek degenerated into garbled output with no report. Both nudged in their terminals to write the report and send worker_done. -- Permission: test-reviewer-deepseek asked a garbled command containing mv and broken redirections. REJECTED; told it to use the file-write tool and send worker_done. -- Re-review 1: test-reviewer-deepseek approve after nudge (F1 fixed, 0 new). Retained. -- Re-review 1: plan-checker-deepseek approve after nudge (N1 dropped, 1 new nit). Retained. All six round-1 reports present. - -## Round 1 merge (six re-reviews, all `approve`, 0 must-fix / 0 should-fix) - -- Fix-list item 1 (config-load registration test): fixed by 5167241; confirmed by test-reviewer-qwen and test-reviewer-deepseek (both say removing the __init__ loop fails the new test). -- plan-checker-deepseek N2 (nit): new test peeks at private _broadcast_sinks, mirroring the existing test. Not sent: nit. -- test-reviewer-deepseek nit: config-load test does not also emit through a SubClient; justified (no bound PUB socket on a never-started server). Not sent: nit. -- Fix list: EMPTY. Task goes to finish. - -## Finish -- All seven workers released (Orca: state retained, processAction none, externally created terminals) and their terminals closed with `orca terminal close`. `worker-list --terminal-state reclaimable` for run_e6f4c00ea2df: 0 rows. -- Checkbox 0.3 set to [x]. - -**Summary.** Outcome: done. Commits: `04c4cbc 0.3: server registers itself as a broadcast sink on Broadcaster instruments`, `5167241 0.3: fix from review round 1: test the config-load sink registration path`. Fix rounds used: 1. Tests (orchestrator run after fix 1): `uv run pytest -q test/pytest/test_broadcaster.py` -> 12 passed in 7.49s; `uv run pytest -q` -> 173 passed, 4 warnings in 67.55s. diff --git a/orchestration/0.3/round-0/fix-list.md b/orchestration/0.3/round-0/fix-list.md deleted file mode 100644 index 5c74053..0000000 --- a/orchestration/0.3/round-0/fix-list.md +++ /dev/null @@ -1,6 +0,0 @@ -# 0.3 — fix list from round 0 - -1. **Add a test for the config-load registration path.** (test-reviewer-qwen F1 must-fix; test-reviewer-deepseek F1 should-fix; the coder itself flagged the gap.) - - Where: `test/pytest/test_broadcaster.py` (server part); code under test `src/instrumentserver/server/core.py:148-152` (the `for component in self.station.components.values(): self._registerBroadcaster(component)` loop in `StationServer.__init__`). - - Why: the plan's Testing table says `test_broadcaster.py` covers "server registers sinks for created **and config-loaded** instruments". Today a removal of the `__init__` loop leaves every test green. - - Suggested shape (from the reviewers; adapt as needed): write a station-config YAML in `tmp_path` defining an instrument of type `instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument` with `initialize: True` (mirror `test/docs_verification/getting_started/quickstartConfig.yml` and the config loading pattern in `test_apps.py` / `instrumentserver.config.loadConfig`). Construct a `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly; registration happens in `__init__`, so no thread or socket bind is needed (use the `server_port` fixture so no fixed port appears). Assert the config-loaded component exists and `server._broadcastParameterChange in component._broadcast_sinks`. Tear down with `qc.Instrument.close_all()` (or whatever the existing fixtures do) so the module-scoped server's instruments are not disturbed. Optionally also emit through the component and confirm a `SubClient` receives it. diff --git a/orchestration/0.3/round-0/plan-checker-deepseek.md b/orchestration/0.3/round-0/plan-checker-deepseek.md deleted file mode 100644 index 343fa92..0000000 --- a/orchestration/0.3/round-0/plan-checker-deepseek.md +++ /dev/null @@ -1,26 +0,0 @@ -# 0.3 — plan-checker-deepseek — round 0 - -Verdict: approve - -## Findings - -No must-fix or should-fix findings. - -### N1 — nit -- Where: src/instrumentserver/server/core.py:146-149 (`__init__`) -- What: The config-load registration loop iterates every `self.station.components.values()` rather than only components just loaded from `serverConfig`. -- Why: The plan says "Call it … for every component after the Station is loaded from config in `__init__`." Registering over all components is a superset of the config-loaded set and stays inside the plan's intent — ADR-0003: "When an instrument joins the Station (creation over the wire, or loading from config at startup) the Server checks `hasattr(instrument, "add_broadcast_sink")` and registers its own broadcast function as a sink." No double registration occurs because `_createInstrument` only registers for instruments newly added to `station.components`, which are not present at `__init__`. -- Suggested fix: none required; leaving as-is is consistent. - -## Notes - -- Scope: commit touches only `server/core.py`, `testing/dummy_instruments/generic.py` (new `DummyBroadcasterInstrument` for the tests), and `test_broadcaster.py`. Nothing outside task 0.3. -- Plan rules checked and met: - - Helper `_registerBroadcaster(instrument)` with `hasattr(instrument, "add_broadcast_sink")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)`. Matches the task line and ADR-0003. - - Called after `self.station.add_component(new_instrument)` in `_createInstrument` (only inside the `if name not in components` branch, so a fresh instrument is registered exactly once), and for config-loaded components in `__init__`. - - One-line comment above `_instrument_locks`: `# Prose calls these the "instrument mutex" (ADR-0003); the code keeps its current names.` Matches the task line and the glossary "Instrument mutex" entry (CONTEXT line 94). `_instrument_locks` is not renamed. -- Vocabulary: all new names and comments use glossary terms — "Broadcaster", "Broadcast", "Broadcast sink", "instrument mutex". No off-glossary words introduced. `SubClient`, `DummyBroadcasterInstrument`, `emit_broadcast`, `param0` are ordinary/consistent with existing code and plan wording. -- Acceptance (task line) met point by point: dummy `Broadcaster` instrument created via `cli.find_or_create_instrument`; method `emit_broadcast` emits a blueprint received by a `SubClient`; plain dummy instrument still works and gets no sink. The config-loaded registration path is implemented but not directly tested; the task's named test list covers only the created and plain-dummy paths, so no missing named test. -- Tests run: - - `uv run pytest test/pytest/test_broadcaster.py -q` → 11 passed in 7.48s. - - `uv run pytest -q` → 172 passed, 4 warnings in 66.86s (the `PytestUnknownMarkWarning` for `integration` is pre-existing and unrelated). \ No newline at end of file diff --git a/orchestration/0.3/round-0/plan-checker-qwen.md b/orchestration/0.3/round-0/plan-checker-qwen.md deleted file mode 100644 index 49c8e5b..0000000 --- a/orchestration/0.3/round-0/plan-checker-qwen.md +++ /dev/null @@ -1,26 +0,0 @@ -# 0.3 — plan-checker-qwen — round 0 - -Verdict: approve - -## Findings - -None. - -Checklist against task 0.3: - -- **Helper**: `_registerBroadcaster(self, instrument)` at `src/instrumentserver/server/core.py:578-587` does exactly the planned `hasattr(instrument, "add_broadcast_sink")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)`. Plan: "helper `_registerBroadcaster(instrument)` doing `hasattr(instrument, \"add_broadcast_sink\")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)`". -- **Call site 1**: `self._registerBroadcaster(new_instrument)` immediately after `self.station.add_component(new_instrument)` in `_createInstrument` (core.py:468-469), inside the same `if new_instrument.name not in self.station.components:` guard, so an existing instrument is not registered twice. Plan: "Call it after `self.station.add_component(new_instrument)` in `_createInstrument`". -- **Call site 2**: loop `for component in self.station.components.values(): self._registerBroadcaster(component)` at core.py:151-152, placed after both `Station(config_file=stationConfig)` (core.py:140, which loads the station config file) and the `load_instrument` loop (core.py:143-146). Plan: "for every component after the Station is loaded from config in `__init__`". `grep` confirms `add_component` and `load_instrument` appear nowhere else in `server/core.py`, matching the plan's "Instruments enter the Station in two places" fact. -- **Comment**: exactly one new line above `_instrument_locks` (core.py:194): `# Prose calls these the "instrument mutex" (ADR-0003); the code keeps its current names.` The preceding "Per-instrument locks…" line pre-exists at base commit 0fbbddf (verified with `git show 0fbbddf:...core.py | grep`). Names untouched, no rename. Plan: "Add a one-line comment above `_instrument_locks` noting prose calls it the \"instrument mutex\" (ADR-0003); do not rename"; ADR-0003 Consequences: "The `_instrument_locks` code in the Server is not renamed or altered". -- **Tests**: `test/pytest/test_broadcaster.py` gains the server part. `test_created_broadcaster_instrument_reaches_subclient` creates a dummy `Broadcaster` instrument (`DummyBroadcasterInstrument`) through `cli.find_or_create_instrument("bcaster", …)`, asserts the server's `_broadcastParameterChange` is a sink on the server-side instrument, calls `inst.emit_broadcast(value=2.5, unit="V")` (a method that emits a blueprint) and asserts a `SubClient` receives exactly one `ParameterBroadcastBluePrint` with `name="bcaster.param0"`, `action="parameter-update"`, `value=2.5`, `unit="V"`. `test_plain_dummy_instrument_still_works_and_gets_no_sink` exercises `dummy.param0` set/get over the wire and asserts `not hasattr(server_dummy, "add_broadcast_sink")`. All three bullets of the plan's test line are covered: "a dummy `Broadcaster` instrument created through `cli.find_or_create_instrument`; calling a method on it that emits a blueprint is received by a `SubClient`; a plain dummy instrument still works and gets no sink". -- **Scope**: commit touches only `src/instrumentserver/server/core.py`, `src/instrumentserver/testing/dummy_instruments/generic.py` (new `DummyBroadcasterInstrument`, required for the named test and shipped in `instrumentserver.testing` per the "Dummy Instrument" glossary entry) and `test/pytest/test_broadcaster.py`. No 0.4/0.5 work (`_newOrDeleteParameterDetection` kwargs, `apps.py` sub_port, action constants), no `params.py`/`blueprints.py` changes, no renames. -- **Vocabulary**: "Broadcaster contract", "Broadcast sink", "joins the Station", "emits a Broadcast", "instrument mutex", "PUB socket", "SubClient", "dummy instrument" — all CONTEXT.md / plan / ADR-0003 terms in their glossary meanings; no "notification", "event emitter", "instrument lock" etc. -- **Protected behaviour**: no existing method signatures or behaviour changed; `_instrument_locks` and the lock code untouched; the `instrumentCreated` emission in `_createInstrument` unchanged. -- **Plan rules**: commit message starts with the task number ("0.3: …"); single atomic commit; tests per layer (server part runs against the module-scoped `start_server` fixture on the `server_port` fixture, no fixed port, per D27). - -## Notes - -- Tests run: `uv run pytest test/pytest/test_broadcaster.py` → `11 passed in 7.54s`; full `uv run pytest` → `172 passed, 4 warnings in 66.70s` (warnings pre-existing: `PytestUnknownMarkWarning` in `test_apps.py`). -- Question for the user (plan, not code): task 0.3 registers sinks at the two entry points the plan names, but `StationServer._runInitScript` (core.py:242, called from `startServer` after `__init__`) passes the live `Station` to user exec-able code, which could add instruments after the `__init__` registration loop. The plan states "Instruments enter the Station in two places" as fact; if init scripts are assumed never to add instruments, this is moot — otherwise a future task could cover it. The commit follows the plan as written. -- Nit (not sent to the coder): the plan's rule 8 says "New methods are `snake_case`" while task 0.3 itself names the helper `_registerBroadcaster` (camelCase, matching the existing server style `_callObject`, `_createInstrument`). The commit follows the task's explicit name, which is the more specific instruction; no action needed. -- The new `capture_broadcasts` helper's docstring reference to `test/docs_verification/helpers.py` is accurate (that file exists and uses the same SubClient-on-QThread pattern). diff --git a/orchestration/0.3/round-0/reviewer-deepseek.md b/orchestration/0.3/round-0/reviewer-deepseek.md deleted file mode 100644 index 3e8939e..0000000 --- a/orchestration/0.3/round-0/reviewer-deepseek.md +++ /dev/null @@ -1,34 +0,0 @@ -# 0.3 — reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -No must-fix or should-fix findings. - -### N1 — nit -- Where: src/instrumentserver/server/core.py:148-152 -- What: The config-load registration loop and its comment sit inside `__init__`, before `broadcastSocket` exists. -- Why: Harmless — `_registerBroadcaster` only appends `self._broadcastParameterChange` to the instrument's sink list; it never invokes the sink, so the `assert self.broadcastSocket is not None` in `_broadcastParameterChange` can only run once a request executes, after `startServer` has bound the socket. -- Suggested fix: None needed; recorded for completeness. - -### N2 — nit -- Where: test/pytest/test_broadcaster.py:159 and src/instrumentserver/testing/dummy_instruments/generic.py:462 -- What: The server test peeks at the private `_broadcast_sinks` list, and `DummyBroadcasterInstrument` exposes a small method solely for the test. -- Why: Checking the private sink list is a direct, honest assertion that the required registration happened (and that a plain dummy gets none), which is exactly what the plan's test spec asks to prove; the per-test dummy method is the minimal way to make an instrument emit through a proxy. Neither warrants a change. -- Suggested fix: None. - -## Notes - -- Reviewed the full diff for `04c4cbc` (`_registerBroadcaster` helper, its two call sites, the `_instrument_locks` comment, `DummyBroadcasterInstrument`, and the server part of `test_broadcaster.py`), plus the surrounding server code, the `Broadcaster` mixin in `base.py`, the three ADRs, `CONTEXT.md`, and `conftest.py`. -- Conformance to the task: - - `_registerBroadcaster(instrument)` does `hasattr(instrument, "add_broadcast_sink")` → `instrument.add_broadcast_sink(self._broadcastParameterChange)` (core.py:586-587). - - Called after `self.station.add_component(new_instrument)` in `_createInstrument` (core.py:469). - - Called for every component after the Station is loaded from config in `__init__` (core.py:151-152; the loop runs after the `self.station.load_instrument(...)` loop and only config-loaded components are in `self.station.components` at that point). - - One-line comment directly above `_instrument_locks` noting prose calls it the "instrument mutex" (ADR-0003) with the code names kept and nothing renamed (core.py:194). - - Tests cover all three plan-specified server cases: created `Broadcaster` instrument reaches a `SubClient` (asserting exactly one message, i.e. no double registration), and a plain dummy instrument still works and stays sink-free. -- A created `Broadcaster` instrument registers exactly once: `_createInstrument` calls `_registerBroadcaster` only when the name is not already in `self.station.components`, and the config path only touches config-loaded instruments, so the two paths cannot double-register the same instrument. -- `broadcast` runs on the worker thread holding the instrument mutex, the same thread the Server's own broadcasts already use, matching ADR-0003. -- Tests run: - - `uv run pytest test/pytest/test_broadcaster.py` → 11 passed in 7.46s. - - `uv run pytest test/pytest/` → 172 passed in 66.35s (whole suite green). \ No newline at end of file diff --git a/orchestration/0.3/round-0/reviewer-qwen.md b/orchestration/0.3/round-0/reviewer-qwen.md deleted file mode 100644 index 324886a..0000000 --- a/orchestration/0.3/round-0/reviewer-qwen.md +++ /dev/null @@ -1,67 +0,0 @@ -# 0.3 — reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -None. - -I checked the commit (`04c4cbc`) line by line against the task and the surrounding code: - -- `_registerBroadcaster` (`src/instrumentserver/server/core.py:578-587`) is exactly the - plan's helper: `hasattr(instrument, "add_broadcast_sink")` → - `instrument.add_broadcast_sink(self._broadcastParameterChange)`. It is called at the two - plan-named sites: after `self.station.add_component(new_instrument)` in - `_createInstrument` (`core.py:468-469`), and for every component after the Station is - loaded from config in `__init__` (`core.py:148-152`). I verified in qcodes 0.58 - (`.venv/.../qcodes/station.py`) that `Station(config_file=...)` only parses the YAML and - that instruments actually enter the Station via `load_instrument` (`core.py:146`), which - runs before the loop — so the loop covers precisely the config-loaded instruments. - `git grep` confirms those two are the only places in `src/` where components enter the - Station (the init script is a third, out-of-scope path, see Notes). -- Registration happens in `__init__` before `broadcastSocket` exists, which is safe: - the bound method only dereferences the socket when it is later invoked on a worker - thread, the same place the Server's own broadcasts already run (ADR-0003 consequence). - Double registration is impossible: a config-loaded instrument later reached by - `find_or_create_instrument` is already in `station.components` and is skipped by the - same `if` guard as `add_component`. -- The one-line comment above `_instrument_locks` (`core.py:194`) notes the "instrument - mutex" prose name (ADR-0003); the name is untouched, as the task requires. -- `DummyBroadcasterInstrument(Broadcaster, Instrument)` - (`src/instrumentserver/testing/dummy_instruments/generic.py:442-465`) has a correct MRO - (`Broadcaster.__init__` → `Instrument.__init__`), and `emit_broadcast` is picked up by - `bluePrintFromInstrumentModule` (public, not on the base class), so it is callable - through the client proxy — which the new test does. -- The two new tests cover both plan scenarios: a Broadcaster created through - `cli.find_or_create_instrument` emitting a blueprint that a `SubClient` receives - (and exactly once, pinning the single-registration semantics), and a plain dummy - instrument still working with no sink. The SubClient thread pattern is the established - one from `test/docs_verification/helpers.py` and is free of the usual Qt - cross-threading traps (`DirectConnection` append runs on the SubClient's thread, so - blocking the main thread in `wait_for_broadcasts` cannot deadlock it). -- Naming and structure follow the file's conventions (camelCase private server methods, - `:param:` docstrings, `ADR-0003` reference style used elsewhere in the ADRs). - -## Notes - -- Tests run: `uv run pytest test/pytest/test_broadcaster.py -v` → `11 passed in 7.46s`; - `uv run pytest` (whole suite) → `172 passed, 4 warnings in 66.33s` (the 4 warnings are - the pre-existing unregistered `pytest.mark.integration` in `test_apps.py`). -- `uv run ruff check` on the three changed files: all passed; `uv run mypy` on the two - `src/` files: no issues. -- The mixin's public methods (`add_broadcast_sink`, `remove_broadcast_sink`, `broadcast`) - show up in instrument blueprints as remotely callable methods (e.g. they would appear - on the Parameter Manager's proxy once it emits). They are inert over the wire — - callables do not survive JSON, and a malformed remote `broadcast(dict)` only reaches - the mixin's logged-and-swallowed sink error path — but it is a design consequence of - task 0.2's public contract, not of this commit. Flagging for the plan checker / a - future task, not a finding here. -- The init script (`_runInitScript`, executed in `startServer`) can add components to - the Station after the `__init__` registration loop; instruments added that way would - not get a sink. The task explicitly scopes registration to the two named sites, and - the plan's architecture section lists the same two entry points, so this is out of - scope — worth keeping in mind for the Phase 6 broadcasts docs page. -- `capture_broadcasts` / `wait_for_broadcasts` in the test duplicate ~25 lines of - `test/docs_verification/helpers.py`; that helper is only importable by the standalone - script convention (`sys.path` insert), so the duplication is justified, and the test - docstring says so. diff --git a/orchestration/0.3/round-0/test-reviewer-deepseek.md b/orchestration/0.3/round-0/test-reviewer-deepseek.md deleted file mode 100644 index e997b8b..0000000 --- a/orchestration/0.3/round-0/test-reviewer-deepseek.md +++ /dev/null @@ -1,17 +0,0 @@ -# 0.3 — test-reviewer-deepseek — round 0 - -Verdict: changes-needed - -## Findings - -### F1 — should-fix -- Where: `test/pytest/test_broadcaster.py` (server part) — no test for the config-load path in `src/instrumentserver/server/core.py:151-152` -- What: The `__init__` config-load registration branch (`for component in self.station.components.values(): self._registerBroadcaster(component)`, core.py:151-152) has no test; the server part covers only the `_createInstrument` branch (line 469). -- Why: The task text and the plan's Testing table both state registration applies to "created and config-loaded instruments", and the commit implements both paths (core.py:151-152 and 469). Currently a `git rm` of the `__init__` loop would leave every test green. This visits a code path the plan says the file must cover. -- Suggested fix: Add a test that starts the server with a `serverConfig` whose `initialize: True` names a Broadcaster instrument (mirroring the config fixtures in `test_apps.py`/`test_config.py`), then assert the sink was registered — e.g. `server.station.components[]._broadcast_sinks` contains `server._broadcastParameterChange` — and ideally repeat the `SubClient`-receives-emission check, mirroring `test_created_broadcaster_instrument_reaches_subclient`. Setup: server constructed with the config; action: `_registerBroadcaster` runs over config-loaded components in `__init__`; expected: the config-tracked instrument has the server as a sink. - -## Notes -- Severity: 0 must-fix, 1 should-fix, 0 nit. -- The three named tests are present and meaningful: (1) `test_created_broadcaster_instrument_reaches_subclient` — created-through-`cli` Broadcaster instrument is a registered sink and a method call that emits a blueprint arrives at a `SubClient` with the right name/action/value/unit and exactly one message; (2) `test_plain_dummy_instrument_still_works_and_gets_no_sink` — a plain dummy works over the wire and has no sink; (3) the unit part covers the mixin unchanged. The "exactly one message" assertion pins that the server registered itself once and that `_callObject` does not double-broadcast a plain method. The instrument-mutex comment (core.py:194) is present verbatim. Vocabulary matches the glossary (Broadcaster, broadcast, sink, instrument mutex). -- Tests run: `uv run pytest test/pytest/test_broadcaster.py` — 11 passed in 7.47s (9 unit + 2 server). -- This review covers only commit 04c4cbc; the 0.2 unit part (Broadcaster mixin) was out of scope. \ No newline at end of file diff --git a/orchestration/0.3/round-0/test-reviewer-qwen.md b/orchestration/0.3/round-0/test-reviewer-qwen.md deleted file mode 100644 index 7d9f97c..0000000 --- a/orchestration/0.3/round-0/test-reviewer-qwen.md +++ /dev/null @@ -1,34 +0,0 @@ -# 0.3 — test-reviewer-qwen — round 0 - -Verdict: changes-needed - -## Findings - -### F1 — must-fix -- Where: `test/pytest/test_broadcaster.py` (server part) + `src/instrumentserver/server/core.py:148-152` -- What: The config-load registration path has no test. The commit adds a loop in `StationServer.__init__` that calls `_registerBroadcaster` for every component loaded from the station config, but no test exercises that path; `test_broadcaster.py` only covers the created-over-the-wire path. -- Why: The plan's Testing table is explicit that this file covers "server registers sinks for created **and config-loaded** instruments", and D23 points at that table as the test plan. The `__init__` loop is brand-new code from this commit and is the only place the config-load requirement ("for every component after the Station is loaded from config in `__init__`") is implemented. No other task names this coverage. If that loop regressed, a `Broadcaster` instrument loaded from config at startup would silently never emit, and no test would fail. -- Suggested fix: Add a test that loads a `DummyBroadcasterInstrument` from config and asserts the server registered itself as its sink. Setup: write a station-config YAML defining `bcaster_cfg` → `instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument` with `initialize: True` (mirror `test/docs_verification/getting_started/quickstartConfig.yml`), derive `serverConfig`/`stationConfig` via `instrumentserver.config.loadConfig` (the pattern `test_apps.py` already uses). Action: construct a `StationServer(port=server_port, serverConfig=..., stationConfig=...)` — registration happens in `__init__`, so no thread or port bind is needed; use the `server_port` fixture and `qapp_session`. Expected: `server.station.components["bcaster_cfg"]` exists and `server._broadcastParameterChange in server.station.components["bcaster_cfg"]._broadcast_sinks` (mirroring the white-box assert in `test_created_broadcaster_instrument_reaches_subclient`); tear down with `qc.Instrument.close_all()`. Optionally also emit via the component and confirm a `SubClient` receives it, to cover the full path. - -### F2 — nit -- Where: `test/pytest/test_broadcaster.py::test_plain_dummy_instrument_still_works_and_gets_no_sink` -- What: The "gets no sink" assertion checks `not hasattr(server_dummy, "add_broadcast_sink")`, which is a property of the dummy's class hierarchy rather than of the Server's behaviour. -- Why: Because registration is gated on `hasattr(instrument, "add_broadcast_sink")`, this is a logically valid proxy and the "still works" set/get part is a genuine behaviour check, so it is acceptable as-is — but a direct `not hasattr(server_dummy, "_broadcast_sinks")` (or asserting `_registerBroadcaster` added nothing) would state the intent more precisely. Preference only. - -### F3 — nit -- Where: `test/pytest/test_broadcaster.py::test_created_broadcaster_instrument_reaches_subclient` -- What: `inst.emit_broadcast(value=2.5, unit="V")` is called but its return value (the `ParameterBroadcastBluePrint` the method returns) is never asserted. -- Why: The plan requires client-facing method return values to be JSON-serialisable blueprints that round-trip through the proxy; asserting the returned blueprint equals what was sent would pin that. The broadcast-on-the-wire is already asserted, so this is a missed opportunity, not a broken test. Preference only. - -## Notes - -- All three tests named in task 0.3's "Tests:" line are present and meaningful: - - `test_created_broadcaster_instrument_reaches_subclient` — creates a `DummyBroadcasterInstrument` via `cli.find_or_create_instrument`, white-box asserts the server is in its `_broadcast_sinks`, then a proxy method call that emits a blueprint is received by a `SubClient` (asserts topic, action, value, unit, and `len(received) == 1` to catch double-registration). Right layer (server/proxy). Fails if the sink is not registered. - - `test_plain_dummy_instrument_still_works_and_gets_no_sink` — plain dummy still set/gets over the wire and has no sink. -- The two new tests are at the correct layer and use the plan's vocabulary (Broadcaster, SubClient, Broadcast). The `capture_broadcasts`/`wait_for_broadcasts` helpers faithfully mirror the established `test/docs_verification/helpers.py` pattern (SubClient on its own QThread, `DirectConnection`, PUB/SUB slow-joiner sleep, GIL-safe list append), so the threading is sound and not flaky-prone by design. -- `DummyBroadcasterInstrument` is added to `src/instrumentserver/testing/dummy_instruments/generic.py`; that module is only imported on demand (both `__init__.py`s are empty), so there is no import-time or circular-import risk, and the existing API is untouched. `Broadcaster.__init__` correctly chains into `Instrument.__init__` and `param0` is added after `super().__init__()`. -- The `_registerBroadcaster` helper and the `_createInstrument` call site match the task exactly; the one-line "instrument mutex" comment is present above `_instrument_locks` and nothing was renamed. -- No existing test was weakened, deleted, or skipped; the nine unit tests from task 0.2 are unchanged. -- Tests run: - - `uv run pytest test/pytest/test_broadcaster.py -v` → `11 passed in 7.45s` - - `uv run pytest` (whole suite) → `172 passed, 4 warnings in 66.92s` (the 4 warnings are pre-existing `pytest.mark.integration` unknown-mark warnings in `test_apps.py`, unrelated to this commit). diff --git a/orchestration/0.3/round-1/fix-list.md b/orchestration/0.3/round-1/fix-list.md deleted file mode 100644 index 776f761..0000000 --- a/orchestration/0.3/round-1/fix-list.md +++ /dev/null @@ -1,3 +0,0 @@ -# 0.3 — fix list from round 1 - -Empty. All six re-reviews approve with no must-fix or should-fix findings. diff --git a/orchestration/0.3/round-1/plan-checker-deepseek.md b/orchestration/0.3/round-1/plan-checker-deepseek.md deleted file mode 100644 index 6772557..0000000 --- a/orchestration/0.3/round-1/plan-checker-deepseek.md +++ /dev/null @@ -1,30 +0,0 @@ -# 0.3 — plan-checker-deepseek — round 1 - -Verdict: approve - -## Previous findings - -### N1 — nit (previous round) -- Where: src/instrumentserver/server/core.py:146-149 (`__init__`) -- What: the `__init__` config-load registration loop iterates every `self.station.components.values()` rather than only components just loaded from `serverConfig`. -- Why: the plan says "Call it … for every component after the Station is loaded from config in `__init__`." Registering over all components is a superset of the config-loaded set but stays inside the plan's intent. -- Status: **dropped by orchestrator** — decisions.md "Round 0 merge": "plan-checker-deepseek N1 (nit): __init__ loop covers all station components, a superset of config-loaded ones. Not sent: nit; matches ADR-0003 intent." The `__init__` loop is unchanged (still at core.py:152) and remains consistent with the plan and ADR-0003, so dropping is fine. - -## Findings (round 1) - -No must-fix or should-fix findings, and no new findings in the plan-checker lane. - -### N2 — nit -- Where: test/pytest/test_broadcaster.py (`test_config_loaded_broadcaster_instrument_gets_sink`) -- What: the new test peeks at the private `component._broadcast_sinks`. -- Why: not a plan rule; it mirrors the existing created-instrument test (`test_created_broadcaster_instrument_reaches_subclient`), which does the same private peek. A server-side public accessor is out of this task's scope. -- Suggested fix: none; consistent with the existing tests. - -## Notes - -- Fix commit 5167241 is test-only: it adds `test_config_loaded_broadcaster_instrument_gets_sink` to `test/pytest/test_broadcaster.py` and changes no `src/` code. The `__init__` register loop (core.py:152) and the `_createInstrument` registration (core.py:469) are intact, matching the plan's two entry points and ADR-0003. -- The new test genuinely guards the config-load path: it writes a config YAML with `instruments:` -> `cfg_bcaster` (`type: instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument`, `initialize: True`), runs it through `loadConfig`, constructs `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly (registration happens in `__init__`; no thread or socket bind needed), and asserts the component exists, is a `Broadcaster`, and that `server._broadcastParameterChange` is in its `_broadcast_sinks`. Removing the `__init__` loop would fail the test, so it is not vacuous. It uses the `server_port` fixture (no fixed port, per D27). Teardown closes the temp file, the wake-up socket pair, and the config-loaded `cfg_bcaster` instrument, leaving the module-scoped `start_server` fixtures unharmed. -- The fix-test vocabulary uses glossary terms: "Broadcaster", "Broadcast sink", `serverConfig`/`stationConfig` — consistent. The optional SubClient emission part was reasonably skipped (a never-started StationServer has no bound PUB socket; the wire path is already covered by the created-instrument test). -- Tests run: - - `uv run pytest test/pytest/test_broadcaster.py -q` -> 12 passed in 7.44s. - - Orchestrator's full suite after fix 1: 173 passed, 4 warnings (per decisions.md). \ No newline at end of file diff --git a/orchestration/0.3/round-1/plan-checker-qwen.md b/orchestration/0.3/round-1/plan-checker-qwen.md deleted file mode 100644 index 4660329..0000000 --- a/orchestration/0.3/round-1/plan-checker-qwen.md +++ /dev/null @@ -1,34 +0,0 @@ -# 0.3 — plan-checker-qwen — round 1 - -Verdict: approve - -## Previous findings - -My round-0 report (orchestration/0.3/round-0/plan-checker-qwen.md) had **no findings** (verdict: approve, 0 must-fix / 0 should-fix / 0 nit), so nothing from my report was fixed, not fixed, or dropped by the orchestrator. The single fix-list item (`orchestration/0.3/round-0/fix-list.md`, item 1: "Add a test for the config-load registration path") came from the test reviewers, not from me. For completeness, my two round-0 non-findings were both dropped by the orchestrator on purpose, per `orchestration/0.3/decisions.md` "Round 0 merge": the nit (`_registerBroadcaster` camelCase vs rule 8 "New methods are `snake_case`" — "Not sent: the task's explicit name wins") and the user-facing Notes (`_runInitScript` could add instruments after the `__init__` loop; plan lists only two entry points) — the latter remains a plan question, not a 0.3 requirement, so it stays out of scope. - -Fix commit 5167241 implements exactly fix-list item 1 and only that: `git diff 04c4cbc 5167241 --stat` shows 46 insertions, 0 deletions, in `test/pytest/test_broadcaster.py` alone. No `src/` change. - -## Did the fix break or weaken anything in my focus area? - -No. - -- **Implementation intact**: the `_registerBroadcaster` helper, both call sites, and the one-line "instrument mutex" comment are unchanged — verified by `git show 5167241:src/instrumentserver/server/core.py` (the `for component in self.station.components.values(): self._registerBroadcaster(component)` loop at core.py:151-152 is byte-identical to round 0). -- **Round-0 tests untouched**: the fix is purely additive; all 11 round-0 tests still pass unchanged. -- **New test stays in task 0.3 scope**: it exercises the task's second call site — plan task 0.3: "for every component after the Station is loaded from config in `__init__`" — and the plan's Testing table line: "server registers sinks for created **and config-loaded** instruments". It uses the real production path: `instrumentserver.config.loadConfig` splits a YAML (same shape as `test/docs_verification/getting_started/quickstartConfig.yml`, which the docstring names) into a station config plus `serverConfig`, and a directly constructed `StationServer` loads `cfg_bcaster` via the `load_instrument` loop in `__init__` (qcodes' `Station(config_file=…)` only reads the config; `load_instrument` is what instantiates and `add_component`s, qcodes/station.py:717). `loadConfig`'s 7-value return order was checked against `src/instrumentserver/config.py:192-200` — the unpack `stationConfigPath, serverConfig, _, _, tempFile, _, _` is correct. -- **Commit rule**: message starts with the task number ("0.3: fix from review round 1: …"); separate fix-round commit, per session protocol step 6 ("each round of review fixes is its own commit"). -- **No fixed port**: uses the `server_port` fixture; `git grep -n "5555\|5599" -- test/pytest` finds nothing (task 0.0 acceptance holds). - -## New findings - -None. - -- **Mutation-sensitive**: the test asserts `server._broadcastParameterChange in component._broadcast_sinks` **and** `len(component._broadcast_sinks) == 1`; removing the `__init__` loop leaves `_broadcast_sinks` empty, so the test fails — it closes exactly the gap the fix list named ("Today a removal of the `__init__` loop leaves every test green"). -- **No interference with other tests**: the new test does not use the `start_server`/`cli` fixtures and creates no instruments on the running server. Constructing the throwaway `Station` reassigns `Station.default`, but in this qcodes version `Station.default` is referenced only by `dataset/measurements.py`, never during instrument creation; the running server holds its own `self.station` reference. Cleanup in `finally` (close `tempFile`, close both ends of the socketpair, close `cfg_bcaster` guarded by `qc.Instrument.exist`) matches what the test opened. The full suite passing confirms no cross-module disturbance. -- **Vocabulary**: "config-loaded broadcaster instrument", "get(s) a sink", "Broadcast sink", "reaches the Station from a config file", "component" (mirrors the plan's "for every component"), "ADR-0003" — all glossary/plan/ADR terms in their glossary meanings. -- **Skipped optional part**: the coder did not add the SubClient-emission part of the fix-list suggestion ("Optionally also emit through the component…"), with a sound reason recorded in `orchestration/0.3/decisions.md` (a never-started `StationServer` has no bound PUB socket; the wire path is already covered by `test_created_broadcaster_instrument_reaches_subclient`). The plan's task line for 0.3 names only the created-instrument and plain-dummy cases, so the mandated coverage (sink registered on a config-loaded instrument) is complete without it. - -## Notes - -- Tests run: `uv run pytest test/pytest/test_broadcaster.py` → `12 passed in 7.45s`; full `uv run pytest` → `173 passed, 4 warnings in 66.66s` (warnings pre-existing: `PytestUnknownMarkWarning` in `test_apps.py`). -- Observation (not a finding, out of scope per plan rule 6 "Do not widen scope"): `loadConfig` returns a `NamedTemporaryFile(delete=False)` and the test (like the rest of the codebase) only closes it, so a temp station-config file is left in the system temp dir per run. That is pre-existing `loadConfig` behaviour, not introduced by this commit. -- Carried-over user question from round 0 (unchanged): if a user init script adds instruments to the Station via `_runInitScript` (core.py:242, runs after `__init__`), those instruments get no sink; the plan names only two entry points, so this remains a plan-level question, not a 0.3 defect. diff --git a/orchestration/0.3/round-1/reviewer-deepseek.md b/orchestration/0.3/round-1/reviewer-deepseek.md deleted file mode 100644 index c01e18c..0000000 --- a/orchestration/0.3/round-1/reviewer-deepseek.md +++ /dev/null @@ -1,31 +0,0 @@ -# 0.3 — reviewer-deepseek — round 1 - -Verdict: approve - -## Previous findings - -Both of my round-0 findings were **dropped by the orchestrator** (decisions.md, "Round 0 merge", lines 45-46: "Not sent: nit"). No fix was requested from the coder on my account; the only fix-list item was the config-load path gap raised by the test-reviewers. - -- N1 — nit (src/instrumentserver/server/core.py:148-152, registration in `__init__` before `broadcastSocket` exists): dropped by orchestrator. Harmless, as my round-0 note said. -- N2 — nit (test peeks at private `_broadcast_sinks`; per-test dummy method): dropped by orchestrator. No change warranted. - -## Did the fix commit break or weaken anything? - -No. `5167241` is test-only (`test/pytest/test_broadcaster.py`, +46 lines); there is no `src/` change, so no runtime behaviour changed and nothing in my focus area (production correctness/clarity) was touched. Suite is fully green: - -- `uv run pytest test/pytest/test_broadcaster.py` → 12 passed in 7.45s (was 11). -- `uv run pytest test/pytest/` → 173 passed in 66.55s. - -The new `test_config_loaded_broadcaster_instrument_gets_sink` test is mutation-sensitive for the code it pins: if the `__init__` loop (core.py:151-152) were removed, `component._broadcast_sinks` would be empty and `assert server._broadcastParameterChange in component._broadcast_sinks` would fail. It also asserts exactly one registration (`len(component._broadcast_sinks) == 1`), matching the created-instrument test's exactly-one message, so a careless double-registration in the config path would be caught here. It uses the `server_port` fixture (no fixed port, per D27), constructs a never-started `StationServer` directly — correct, since the loop runs in `__init__` and no socket bind/thread is needed — and its `finally` block closes the temp file, the socketpair, and the `cfg_bcaster` instrument, so it leaves no qcodes state behind that could disturb the other tests in the same file. - -The `loadConfig` unpack matches the function's actual 7-tuple return (config.py:192-200), and the YAML written mirrors the production config shape the quickstart config uses. - -## New findings - -None in my scope (correctness, clarity, consistency). The change is purely additive test coverage of the config-load registration path, which closes the one real coverage gap the round-0 test-reviewers raised (and that the plan's Testing-table row for `test_broadcaster.py` names: "created and config-loaded instruments"). - -## Notes - -- Verified the fix commit with `git show 5167241 --stat` and `git show 5167241`: only `test/pytest/test_broadcaster.py` touched; the `__init__` registration loop in `core.py` is intact. -- Reviewed the new test in the context of `loadConfig` (instrumentserver/config.py:21-200), `StationServer.__init__` (core.py:106-199), and the existing server tests in the file. -- Whole suite re-run by me: 173 passed in 66.55s. \ No newline at end of file diff --git a/orchestration/0.3/round-1/reviewer-qwen.md b/orchestration/0.3/round-1/reviewer-qwen.md deleted file mode 100644 index 31ecfd7..0000000 --- a/orchestration/0.3/round-1/reviewer-qwen.md +++ /dev/null @@ -1,68 +0,0 @@ -# 0.3 — reviewer-qwen — round 1 - -Verdict: approve - -## Previous findings - -My round-0 report had no findings (verdict: approve; three informational Notes only), so -there is nothing to be fixed or dropped. The single fix-list item (test for the -config-load registration path) came from test-reviewer-qwen/deepseek and the coder's own -flag, not from my report. For the record, my two round-0 notes (the init-script -registration gap, out of scope per the task; the mixin's wire-callable public methods, a -0.2 consequence) are carried in `decisions.md` as "Notes for the user" and remain -informational. - -## Fix commit review (5167241) - -The commit adds one test, `test_config_loaded_broadcaster_instrument_gets_sink`, to -`test/pytest/test_broadcaster.py` (+46 lines; `git diff 04c4cbc..5167241 --stat` confirms -no other file changed, so my round-0 code-level conclusions stand). - -- **It pins the right code.** The test writes a production-style server config - (`instruments: cfg_bcaster: {type: ...DummyBroadcasterInstrument, initialize: True}`), - splits it with `loadConfig` (unpacking the 7-tuple return in the correct order, - verified against `src/instrumentserver/config.py:192-199`), and constructs - `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly. - In qcodes 0.58 the config-loaded instrument reaches the Station only through - `load_instrument` inside `__init__` (qcodes `station.py:717` `add_component`), so the - registration loop at `src/instrumentserver/server/core.py:151-152` is the *only* code - that can put `server._broadcastParameterChange` into `component._broadcast_sinks`. - The `in ...` plus `len(...) == 1` asserts therefore fail if the loop is removed — the - test genuinely covers the previously untested path. -- **No interference with the running suite state.** The `StationServer` is constructed - but never started: no port is bound (no collision with the module-scoped server on the - same `server_port`), no thread is spawned. Its side effects are all cleaned up: the - `loadConfig` temp-file handle and both wakeup socketpair ends are closed in `finally`, - and `cfg_bcaster` is closed via `qc.Instrument.find_instrument(...).close()` guarded by - `exist()`. The one residual — the new `Station` becomes `Station.default` - (qcodes `station.py:164`, strong reference) and outlives the test — is harmless: the - only qcodes consumer of `Station.default` is `Measurements.__init__` - (`measurements.py:661`), which neither this codebase nor the test suite uses, and the - live module server holds an explicit `self.station` reference. `update_monitor()` in - `load_instrument` does not open a Qt Monitor (default `station.use_monitor` is - falsy). -- **The skipped optional part is reasonable.** The fix list suggested optionally emitting - through the component to a `SubClient`; the coder skipped it because a never-started - `StationServer` has no bound PUB socket and `_broadcastParameterChange` asserts on - `broadcastSocket`. The wire path is already covered by the created-instrument test. - Noted in `decisions.md`; no objection. -- **Nothing weakened.** The two existing server tests are untouched; the new test adds no - fixture dependencies beyond `tmp_path` / `server_port` / `qapp_session` and would pass - standalone even without the module server. - -## New findings - -None. - -## Notes - -- Tests run: `uv run pytest test/pytest/test_broadcaster.py -v` → `12 passed in 7.45s`; - `uv run pytest` (whole suite) → `173 passed, 4 warnings in 66.43s` (same pre-existing - `pytest.mark.integration` warnings as round 0). `uv run ruff check - test/pytest/test_broadcaster.py` → all checks passed. -- White-box style: the new test reaches into `StationServer` privates - (`_wakeup_r`/`_wakeup_w`) and the mixin's `_broadcast_sinks`, matching the existing - pattern in this file and in `conftest.py` (`server.SAFEWORD`); consistent, no finding. -- `loadConfig` writes a `NamedTemporaryFile(delete=False)` that persists on disk after - the test (only the handle is closed); this is pre-existing production behaviour in - `config.py`, identical to how `apps.py` uses it, and not changed by this commit. diff --git a/orchestration/0.3/round-1/test-reviewer-deepseek.md b/orchestration/0.3/round-1/test-reviewer-deepseek.md deleted file mode 100644 index 2a67d11..0000000 --- a/orchestration/0.3/round-1/test-reviewer-deepseek.md +++ /dev/null @@ -1,33 +0,0 @@ -# 0.3 — test-reviewer-deepseek — round 1 - -Verdict: approve - -## Previous findings - -### F1 — should-fix: `__init__` config-load registration path untested -- Status: **fixed** by commit 5167241. -- `test_config_loaded_broadcaster_instrument_gets_sink` (added in 5167241) constructs a - `StationServer` directly with a `serverConfig`/`stationConfig` naming a - `DummyBroadcasterInstrument` with `initialize: True`, and asserts the config-loaded - component carries `server._broadcastParameterChange` in `_broadcast_sinks` (length 1). - Removing the `__init__` loop (`core.py:151-152`) leaves `_broadcast_sinks` empty and fails - the assert, so the test genuinely pins the previously-untested branch. The wire path is - already covered end-to-end by `test_created_broadcaster_instrument_reaches_subclient`. - -## Findings - -No must-fix or should-fix findings in this round. - -- (nit, not sent) The config-loaded test skips emitting through the component and confirming - a `SubClient` receives it; this is justified because a never-started `StationServer` has no - bound PUB socket, and the wire path is independently covered. The registration the - `__init__` loop performs is asserted directly, which is the point of the test. - -## Notes -- The fix commit touches only `test/pytest/test_broadcaster.py`; no `src/` change. Existing - tests are untouched (no deletion, skip, or weakening). -- New test hygiene is sound: uses the `server_port` fixture (no fixed port), cleans up the - temp config file, wakeup socketpair, and closes the `cfg_bcaster` instrument so the - module-scoped server's instruments are undisturbed; the module-level - `_close_instruments_between_modules` fixture also runs `close_all()`. -- Tests run: `uv run pytest test/pytest/test_broadcaster.py` — 12 passed in 7.47s. \ No newline at end of file diff --git a/orchestration/0.3/round-1/test-reviewer-qwen.md b/orchestration/0.3/round-1/test-reviewer-qwen.md deleted file mode 100644 index 9a725ce..0000000 --- a/orchestration/0.3/round-1/test-reviewer-qwen.md +++ /dev/null @@ -1,24 +0,0 @@ -# 0.3 — test-reviewer-qwen — round 1 - -Verdict: approve - -## Previous findings - -- **F1 (must-fix — config-load registration path untested): FIXED.** The fix commit adds `test_config_loaded_broadcaster_instrument_gets_sink` (`test/pytest/test_broadcaster.py:240`), which does exactly what the fix list asked: it writes a station-config YAML defining `cfg_bcaster` → `DummyBroadcasterInstrument` with `initialize: True`, splits it with `loadConfig`, constructs a `StationServer(port=server_port, serverConfig=..., stationConfig=...)` directly, and asserts the component exists, `isinstance(component, Broadcaster)`, `server._broadcastParameterChange in component._broadcast_sinks`, and `len(component._broadcast_sinks) == 1`. I verified the `__init__` loop it covers is intact (`core.py:148-152`, unchanged from 04c4cbc), and the test is meaningful: for a config-loaded instrument the only sink-registration path is that loop (`_createInstrument` is not on this path), so removing the loop would leave `_broadcast_sinks` empty and both the membership and length asserts would fail. The coder skipped the optional SubClient emission step with a sound reason (an un-started `StationServer` has no bound PUB socket, so emission would trip the `assert self.broadcastSocket is not None` in `_broadcastParameterChange`); the plan's table requires "server registers sinks for … config-loaded instruments" — registration is what is asserted — and the wire path is covered by `test_created_broadcaster_instrument_reaches_subclient`. The teardown is actually safer than my suggestion: it closes the temp config file, the `__init__` wakeup socketpair, and only the `cfg_bcaster` instrument itself (not `close_all()`), so the module-scoped server's instruments are undisturbed. -- **F2 (nit — "gets no sink" asserted via the class rather than server state): DROPPED by orchestrator** (Round 0 merge: "Not sent: nit"). Still present, unchanged (`test_plain_dummy_instrument_still_works_and_gets_no_sink` line 237). Intentional; no action expected. -- **F3 (nit — `emit_broadcast` return value not asserted): DROPPED by orchestrator** (Round 0 merge: "Not sent: nit"). Still present; the created-path test is unchanged by the fix commit. Intentional; no action expected. - -## Did the fix commit break or weaken anything? - -No. Commit 5167241 touches only `test/pytest/test_broadcaster.py` (new imports `qcodes`, `loadConfig`, `StationServer`, plus the one new test); no `src/` change. I re-verified the `__init__` registration loop is intact at `core.py:148-152` (consistent with the orchestrator's mutation-check record). No existing test was modified, weakened, or skipped — all 11 pre-existing tests in the file still pass. The new test is deterministic (no sockets, no timing, runs last in the file, does not use the module server fixtures), so it adds no flakiness risk and cannot interfere with the module-scoped `start_server`. - -## New findings - -None. The new test is at the right layer (server, unit-style construction of `StationServer`), its name is accurate and in the plan's vocabulary (config, Broadcaster, sink), and it fails if the config-load registration regresses. - -## Notes - -- Tests run: - - `uv run pytest test/pytest/test_broadcaster.py -v` → `12 passed in 7.47s` - - `uv run pytest` (whole suite) → `173 passed, 4 warnings in 66.51s` (the 4 warnings are the same pre-existing `pytest.mark.integration` unknown-mark warnings in `test_apps.py`, unrelated to this commit). -- Both registration points named in task 0.3 now have a dedicated test: created-over-the-wire (`test_created_broadcaster_instrument_reaches_subclient`) and config-loaded (`test_config_loaded_broadcaster_instrument_gets_sink`), matching the plan's Testing table for `test_broadcaster.py`. diff --git a/orchestration/0.4/decisions.md b/orchestration/0.4/decisions.md deleted file mode 100644 index f2c41a6..0000000 --- a/orchestration/0.4/decisions.md +++ /dev/null @@ -1,49 +0,0 @@ -# 0.4 Pre-existing fixes (D24, first two) — decisions log - -Run: run_e6f4c00ea2df. Branch: marcosfrenkel/new-param-manager. Base commit: 88eeda0978cae2f3aa5ec76bac7c50444fb6bd85. - -## Workers - -| agent id | terminal handle | current dispatch id | -|---|---|---| -| plan-checker-qwen | term_7da87644-1284-4941-a634-0c49ef2fed22 | ctx_32138e7e96b9 (task_dc2d726e35aa, round 0) | -| plan-checker-deepseek | term_d339209a-287a-406c-b55d-dec40d32885d | ctx_0f25b3317a4d (task_27e70ad56463, round 0) | -| test-reviewer-qwen | term_68349c14-9cec-4e9a-b7d6-5bf81057dccb | ctx_9dc81465d35c (task_a2cc5b175f7f, round 0) | -| test-reviewer-deepseek | term_ecefb21d-50b0-4e4c-b311-700961f290c6 | ctx_5ddc99b46da4 (task_5d5d55a85fe4, round 0) | -| reviewer-qwen | term_e2ed50a3-68d0-4322-8c96-acf88fd9c980 | ctx_3251bebf2f6c (task_1221a2fc837e, round 0) | -| reviewer-deepseek | term_83491358-911e-447e-8029-7a6aabc0d3d5 | ctx_edd8b4b63a12 (task_74070a014957, round 0) | -| coder | term_153dd2fe-6067-44c9-9e20-ea12cc6d8e43 | ctx_1d64da13fd1e (task_72a86c9e1394, first implementation) | - -## Log - -- Checkbox 0.4 set to [~]. Base 88eeda0. -- Coder dispatched for first implementation (task_72a86c9e1394 / ctx_1d64da13fd1e). -- Permission: coder asked python/uv import qcodes location + ls of venv dirs (read-only). Allowed once. -- Coder worker_done (succeeded). Commit 56ece34 "0.4: fix latent KeyError in parameter-creation broadcast and pass broadcast port to the parameter manager GUI launcher"; files: src/instrumentserver/apps.py, src/instrumentserver/server/core.py, test/pytest/test_apps.py, test/pytest/test_param_manager.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files, nothing dirty outside orchestration/ and the plan. Coder retained. -- Coder judgment call: added `type=int` to the launcher's `--port` argparse argument so `args.port + 1` (the plan's prescribed expression) works; before, the port reached Client as a string. Orchestrator view: required by the task text; reviewers will judge. -- Orchestrator tests: `uv run pytest -q test/pytest/test_apps.py test/pytest/test_param_manager.py` -> 31 passed in 12.28s; `uv run pytest -q` -> 174 passed, 4 warnings in 68.94s. -- Six reviewers dispatched for round 0 (target 88eeda0..56ece34). -- test-reviewer-qwen worker_done (succeeded, approve, 2 nits). Retained. -- plan-checker-qwen worker_done (succeeded, approve, 0 findings; type=int judged in scope). Retained. -- reviewer-qwen worker_done (succeeded, approve, 1 nit: duplicated capture helper). Retained. -- plan-checker-deepseek and test-reviewer-deepseek stalled on provider 'Upstream error'; reviewer-deepseek degenerated into garbled output. No reports. All three nudged in their terminals to resume and report. -- Correction: the first nudge never reached the three terminals (orchestrator shell bug: empty handle). Re-sent successfully ~10 min later. -- Permission: plan-checker-deepseek asked access to a garbled '/Users:/Users/...' path outside the repo. REJECTED; told it to use the relative report path. -- Permission: plan-checker-deepseek asked rm of its own report file orchestration/0.4/round-0/plan-checker-deepseek.md to rewrite it. Allowed once (its own file, under orchestration/). -- Round 0 deepseek reviewers after the nudge: reviewer-deepseek wrote a complete report (approve, 1 nit) but hit a provider error before worker_done; test-reviewer-deepseek's report has approve + 1 nit but a garbled Notes tail; plan-checker-deepseek wrote only a skeleton (approve, no findings). All three nudged again with specific instructions. -- plan-checker-deepseek worker_done after nudges (succeeded, approve, 0 findings). Retained. -- test-reviewer-deepseek worker_done after nudges (succeeded, approve, 1 nit). Retained. -- reviewer-deepseek worker_done after nudges (succeeded, approve, 1 nit). Retained. All six round-0 reports present. - -## Round 0 merge (six reports, all `approve`; 0 must-fix / 0 should-fix) - -- reviewer-qwen F1 + test-reviewer-qwen F1 + reviewer-deepseek note: `capture_broadcasts`/`wait_for_broadcasts` copied verbatim from test_broadcaster.py into test_param_manager.py. Nit by all who raised it; not a plan rule. Not sent: nit. Worth consolidating into conftest.py when a third copy appears (tasks 1.3 / 2.5). -- test-reviewer-qwen F2 + test-reviewer-deepseek F1: launcher tests only exercise the `--port 4567` path, not the default-port path. Not sent: nit; the plan asks only to extend the two existing tests, which was done. -- reviewer-deepseek F1 (nit): `type=int` on `--port` is a judgment call beyond D24's literal text; every reviewer (both plan-checkers explicitly) judges it in scope as a prerequisite for `args.port + 1`. Not sent; accepted. -- Fix list: EMPTY. Task goes to finish. - -## Finish -- All seven workers released (Orca: state retained, processAction none) and their terminals closed. `worker-list --terminal-state reclaimable` for run_e6f4c00ea2df: 0 rows. -- Checkbox 0.4 set to [x]. - -**Summary.** Outcome: done. Commits: `56ece34 0.4: fix latent KeyError in parameter-creation broadcast and pass broadcast port to the parameter manager GUI launcher`. Fix rounds used: 0. Tests (orchestrator): `uv run pytest -q test/pytest/test_apps.py test/pytest/test_param_manager.py` -> 31 passed in 12.28s; `uv run pytest -q` -> 174 passed, 4 warnings in 68.94s. diff --git a/orchestration/0.4/round-0/fix-list.md b/orchestration/0.4/round-0/fix-list.md deleted file mode 100644 index 0b40a33..0000000 --- a/orchestration/0.4/round-0/fix-list.md +++ /dev/null @@ -1,3 +0,0 @@ -# 0.4 — fix list from round 0 - -Empty. All six reviewers approve; every finding is a nit (not sent). diff --git a/orchestration/0.4/round-0/plan-checker-deepseek.md b/orchestration/0.4/round-0/plan-checker-deepseek.md deleted file mode 100644 index 12f0f4e..0000000 --- a/orchestration/0.4/round-0/plan-checker-deepseek.md +++ /dev/null @@ -1,10 +0,0 @@ -# 0.4 - plan-checker-deepseek - round 0 -Verdict: approve -## Findings -None. - -## Notes -- Scope: changes limited to apps.py, server/core.py, test_apps.py, test_param_manager.py; single commit 56ece34. -- Plan rule checks: both D24 fixes applied as specified; glossary terms respected; named tests present. -- type=int addition: necessary enabler for args.port + 1 and within scope, not a scope widening. -- Tests run: 31 passed for the two named files; full suite 174 passed. \ No newline at end of file diff --git a/orchestration/0.4/round-0/plan-checker-qwen.md b/orchestration/0.4/round-0/plan-checker-qwen.md deleted file mode 100644 index 64e4936..0000000 --- a/orchestration/0.4/round-0/plan-checker-qwen.md +++ /dev/null @@ -1,80 +0,0 @@ -# 0.4 — plan-checker-qwen — round 0 - -Verdict: approve - -## Findings - -(None.) - -## Notes - -- Commit 56ece34 is a single commit touching exactly the four files task 0.4 names: - `src/instrumentserver/apps.py`, `src/instrumentserver/server/core.py`, - `test/pytest/test_apps.py`, `test/pytest/test_param_manager.py`. Commit message - starts with `0.4:` per session protocol step 6. - -- **Acceptance, point by point** (task text: "0.4 Pre-existing fixes (D24, first two)"): - 1. `_newOrDeleteParameterDetection` now uses `kwargs.get("initial_value")` and - `kwargs.get("unit", "")` (server/core.py:629-630) — exactly as specified. - 2. `parameterManagerScript` passes `sub_port=args.port + 1, sub_host="localhost"` - into `ParameterManagerGui` (apps.py:149) — exactly as specified. I verified the - kwargs are real: `ParameterManagerGui.__init__` forwards `**kwargs` to - `InstrumentParameters`, which pops `sub_host`/`sub_port` into `ModelParameters`, - which hands them to `SubClient` (gui/instruments.py:413-424, 555-558). - 3. Both existing param-manager launcher tests in `test_apps.py` - (`test_param_manager_script_instrument_exists`, - `test_param_manager_script_instrument_missing`) are extended to assert - `mock_pmg.assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost")` - for `--port 4567`. - 4. The named proxy test exists: - `test_add_parameter_without_initial_value_succeeds_and_broadcasts` in - `test_param_manager.py`, using the `param_manager` proxy fixture against the live - server. It calls `params.add_parameter("x")` with no `initial_value`/`unit`, asserts - success, exactly one Broadcast, and `bp.action == "parameter-creation"`, - `bp.value is None`, `bp.unit == ""`. - -- **The `type=int` judgment call is in scope, not scope creep.** Task 0.4 specifies - `sub_port=args.port + 1`; argparse without `type=` yields a string for a CLI-passed - `--port`, so `args.port + 1` would raise `TypeError` (and the task's own tests use - `--port 4567` and assert the int `sub_port=4568`, which cannot pass without it). The - change is the minimum needed to make the specified expression work, matches - `Client.__init__(port: int)` (client/proxy.py:453), and touches only the - `parameterManagerScript` parser — `serverScript`, `detachedServerScript` and - `clientStationScript` keep their existing string-port behaviour, which rule 6 ("Do - not widen scope. Pre-existing defects not listed in Phase 0 are noted in - `TEST_AUDIT.md`, not fixed.") requires leaving alone. The pinned test - `test_server_script_passthrough_args` still asserts `kwargs["port"] == "9999"` and - passes. - -- **D24 item three untouched.** `ParameterManagerTreeView.onItemNewValue` - (gui/instruments.py) is not modified — correct, it belongs to task 5.1 ("Fix D24 item - three: `ParameterManagerTreeView.onItemNewValue` uses `widget._setMethod(value)`"). - No work from other tasks appears in the commit. - -- **The proxy test can fail on unfixed code.** Before the fix, `kwargs["initial_value"]` - raised `KeyError` after `obj(*args, **kwargs)` in `_invoke()`, so the server answered - with `ServerResponse(error=...)` and `BaseClient.ask` (default - `raise_exceptions=True`) would raise from `params.add_parameter("x")`. The new test - therefore fails pre-fix and passes post-fix; it is a genuine regression test, and it - also pins the wire format (value serialises as `"None"` → `None` on - deserialisation; `""` round-trips as `""` — verified against - `bluePrintToDict`/`deserialize_obj` in blueprints.py). - -- **Vocabulary.** New test names, docstrings and comments use "Broadcast" / "Broadcasts" - per CONTEXT.md; the helpers `capture_broadcasts` / `wait_for_broadcasts` mirror the - identically named helpers already committed in task 0.3's `test_broadcaster.py`. - Action string `"parameter-creation"` stays a literal, as D10 requires ("Action strings - stay strings; module-level constants for all actions" — the constants are task 0.5). - -- **Decisions and ADRs.** Consistent with ADR-0003 ("Direct `add_parameter` / - `remove_parameter` calls keep being announced by the Server"), D24, and D27 (the - SubClient port comes from the `server_port` fixture, `server_port + 1`, never a fixed - port). Rule 7 protected APIs: no signature or behaviour change to `add_parameter` or - any other listed method. - -- **Tests run.** - - `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py -v` → - `31 passed in 12.47s`. - - `uv run pytest` (whole suite) → `174 passed, 4 warnings in 68.71s`. The 4 warnings - are pre-existing `PytestUnknownMarkWarning`s for `@pytest.mark.integration` in - `test_apps.py`, unrelated to this commit. diff --git a/orchestration/0.4/round-0/reviewer-deepseek.md b/orchestration/0.4/round-0/reviewer-deepseek.md deleted file mode 100644 index ff0fc42..0000000 --- a/orchestration/0.4/round-0/reviewer-deepseek.md +++ /dev/null @@ -1,21 +0,0 @@ -# 0.4 — reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/test_param_manager.py:62 -- What: The `capture_broadcasts` / `wait_for_broadcasts` helpers duplicate the capture pattern already in `test_broadcaster.py`. -- Why: Pure test-side duplication; not production dead code and not in the general reviewer's lane to enforce. -- Suggested fix: Optionally factor a shared capture helper into `conftest.py` when later tasks (2.5, 3.2) also need broadcast capture. Not required for this task. - -## Notes - -- `_newOrDeleteParameterDetection` (server/core.py:624-631) now reads `kwargs.get("initial_value")` / `kwargs.get("unit", "")`, fixing the latent `KeyError` that D24 lists. When the args are absent the broadcast carries `value=None` / `unit=""`, which match `ParameterBroadcastBluePrint`'s own defaults (`value: int | None = None`, `unit: str = ""`). -- `apps.py:parameterManagerScript` passes `sub_port=args.port + 1, sub_host="localhost"` into `ParameterManagerGui`. Wiring verified end to end: `ParameterManagerGui.__init__` forwards `**kwargs` to `InstrumentParameters.__init__`, which pops `sub_port`/`sub_host` into `modelKwargs` (instruments.py:555-558), consumed by `ModelParameters` for its `SubClient` (instruments.py:413-424). `args.port + 1` matches the server's broadcast-port convention (server binds `port`, broadcasts on `port + 1`), so the GUI now follows `--port`. -- **Judgement on `type=int`:** it is required for the plan-mandated `args.port + 1` to be valid arithmetic — `args.port` would otherwise stay a `str` and `args.port + 1` would raise `TypeError`. It also makes the CLI match `Client(port: int)`'s signature (client/core.py:32). This is a necessary prerequisite for the task's specified change, not a scope-widening extra fix, so it does not violate plan rule 6 ("Do not widen scope"). Side effect: argparse now rejects a non-integer `--port`, which is strictly more correct. -- Caller context (server/core.py:483-487): `_newOrDeleteParameterDetection` runs after the call; the fix only changes how the broadcast payload is built, not the add/remove flow. -- Tests: `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py -q` → `31 passed in 12.42s`. The two launcher tests assert `sub_port=4568, sub_host="localhost"`; the new proxy test `test_add_parameter_without_initial_value_succeeds_and_broadcasts` covers the no-`initial_value`/`unit` path over the wire using `server_port + 1`. - -Verdict is approve: no must-fix or should-fix findings. \ No newline at end of file diff --git a/orchestration/0.4/round-0/reviewer-qwen.md b/orchestration/0.4/round-0/reviewer-qwen.md deleted file mode 100644 index 304604a..0000000 --- a/orchestration/0.4/round-0/reviewer-qwen.md +++ /dev/null @@ -1,23 +0,0 @@ -# 0.4 — reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/test_param_manager.py:63-97 -- What: `capture_broadcasts` and `wait_for_broadcasts` are copied verbatim (~35 lines) from test/pytest/test_broadcaster.py:166-202 (only the docstring's cross-reference differs). -- Why: Two identical copies of a non-trivial QThread + PUB/SUB slow-joiner helper can drift; the plan adds further proxy broadcast tests in tasks 1.3 and 2.5 (test_pm_locks.py, test_pm_types.py), so the duplication is likely to multiply. Not a correctness issue today — both copies pass — and cross-importing between test modules is fragile (a module is only importable when its file was collected), which is probably why the coder copied instead. -- Suggested fix: If desired, move both helpers into test/pytest/conftest.py so every module gets one copy; otherwise leave as is and let the first task that adds a third copy consolidate. - -## Notes - -- Tests run: - - `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py` → `31 passed in 12.46s`. - - `uv run pytest` (whole suite) → `174 passed, 4 warnings in 69.14s`. All 4 warnings are pre-existing `PytestUnknownMarkWarning` for the unregistered `integration` mark in the old test_apps.py integration tests; not introduced by this commit. -- Server fix (src/instrumentserver/server/core.py:624-630): matches D24 verbatim. `kwargs.get("initial_value")` yields `None` and `kwargs.get("unit", "")` yields `""`, which are exactly the `ParameterBroadcastBluePrint` dataclass defaults (blueprints.py:362-363), so the empty-payload broadcast is well-formed. `None` round-trips through the wire format: `bluePrintToDict` → `json.dumps` (null) → `deserialize_obj` returns `None` for null values (blueprints.py:935-936), confirmed by the passing proxy test asserting `bp.value is None`. -- Positional-args edge case considered and dismissed: `_newOrDeleteParameterDetection` reads only `kwargs`, so a positional `add_parameter("x", 5, "V")` would broadcast `value=None, unit=""`. This cannot happen for the Parameter Manager in practice: `ParameterManager.add_parameter(name, **kw)` is keyword-only (params.py:123), so a positional call raises `TypeError` on the server before the detection runs (server/core.py:484) — no half state, no wrong payload. All real callers use kwargs (GUI: gui/instruments.py:830-833; proxy: client/proxy.py:290). D24 prescribes exactly this form, so no finding. -- Launcher fix (src/instrumentserver/apps.py:145-151): verified the kwargs chain. `ParameterManagerGui.__init__` forwards `**kwargs` to `InstrumentParameters.__init__`, which pops `sub_host`/`sub_port` into the model kwargs (gui/instruments.py:756-772, 555-558) and they reach `SubClient` (client/proxy.py:681-697). `sub_host="localhost"` matches the implicit host of `Client(port=args.port)` (client/proxy.py:452 defaults host to "localhost"), so the two stay consistent. -- Judgment call on `type=int` (apps.py:128): correct, and within D24's scope. Without it, argparse returns `--port` as a string, and `args.port + 1` would raise `TypeError` for every explicit `--port` — precisely the custom-port path D24 exists to fix (the no-flag default `5555` is the only case that would have worked). It also aligns `parameterManagerScript` with `clientStationScript`, which already uses `type=int` for `--port` (apps.py:173). No existing behaviour breaks: `BaseClient` only interpolates the port into an f-string address (client/core.py:43), so int and str ports behave identically on the wire; the only behavioural change is that invalid input (`--port abc`) now fails at parse time with a clear argparse error instead of later at connect. `serverScript`/`detachedServerScript` still have string-typed `--port` (apps.py:45, 161) — pre-existing, explicitly outside D24 (which names only the parameter-manager launcher), and pinned by existing tests that assert string values (`port == "9999"`, `port="9000"` in test_apps.py:269, 345-347); correctly left untouched. -- Both extended launcher tests (test_apps.py:355-418) assert the exact kwargs `sub_port=4568, sub_host="localhost"` and would fail against the pre-fix code; the new proxy test would also fail pre-fix (the latent KeyError surfaces as a failed remote call) and on a wrong sub-port (timeout in `wait_for_broadcasts`), so the named tests can fail and pin the behaviour. -- New proxy test uses the module-scoped `server_port` fixture per D27 (`server_port + 1` for the broadcast port), no fixed ports; the parameter name `"x"` does not collide with parameters created by earlier tests in the module. diff --git a/orchestration/0.4/round-0/test-reviewer-deepseek.md b/orchestration/0.4/round-0/test-reviewer-deepseek.md deleted file mode 100644 index ec2c2ae..0000000 --- a/orchestration/0.4/round-0/test-reviewer-deepseek.md +++ /dev/null @@ -1,17 +0,0 @@ -# 0.4 — test-reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/test_apps.py:355, :386 (the two param-manager launcher tests) -- What: The two launcher unit tests pass an already-integer port (4567) directly through `sys.argv`, so they exercise the argparse parser only for `sub_port = args.port + 1` behaviour (asserting `sub_port=4568`), not the string-to-int conversion itself. -- Why: This is a harmless gap. If `--port` stayed a string, `args.port + 1` would yield `"45671"` and both asserts would still fail, so the tests do catch a missing `type=int`. The coder's `type=int` addition is a judgment call outside the plan's literal wording, but it is effectively required for `args.port + 1` to work and the existing asserts would catch its regression. Not worth sending to the coder. -- Suggested fix: none. - -## Notes -- Plan task 0.4 requires exactly the three things implemented: (1) `kwargs.get("initial_value")` / `kwargs.get("unit", "")` in `_newOrDeleteParameterDetection` (src/instrumentserver/server/core.py:627-631); (2) `sub_port=args.port + 1`, `sub_host="localhost"` into `ParameterManagerGui` (src/instrumentserver/apps.py:146-152); (3) the named tests — `test_apps.py` extended (asserts `sub_port=4568, sub_host="localhost"` in both launcher paths) and the new proxy test in `test_param_manager.py`. -- New proxy test `test_add_parameter_without_initial_value_succeeds_and_broadcasts`: correct layer (uses the `param_manager` proxy fixture over the real server plus a `SubClient` on `server_port + 1`), and meaningful — without the fix the server-side `kwargs["initial_value"]` raises a `KeyError` inside `_invoke()`, breaking the client call, so the test fails against the old code. It asserts parameter existence, exactly one broadcast, `name == "parameter_manager.x"`, `action == "parameter-creation"`, `value is None`, `unit == ""`. -- Broadcast port convention matches production: the server binds `port` for requests and broadcasts on `port + 1` (conftest `server_port` docstring), so `sub_port=server_port + 1` is correct. -- Tests run: `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py -q` → 31 passed in 12.28s. \ No newline at end of file diff --git a/orchestration/0.4/round-0/test-reviewer-qwen.md b/orchestration/0.4/round-0/test-reviewer-qwen.md deleted file mode 100644 index f58aa1b..0000000 --- a/orchestration/0.4/round-0/test-reviewer-qwen.md +++ /dev/null @@ -1,28 +0,0 @@ -# 0.4 — test-reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: test/pytest/test_param_manager.py:63-97 -- What: `capture_broadcasts` and `wait_for_broadcasts` are copied verbatim from test_broadcaster.py:166-202. -- Why: two identical SubClient-capture helpers now live in two test modules; if the capture pattern changes (e.g. thread join, slow-joiner delay) both copies must be updated. Not a plan rule — the plan's Testing section does not require a shared helper, and the coder's docstring notes the pattern is deliberately followed. -- Suggested fix: none required for this task; if a third module needs it (test_pm_locks.py / test_pm_types.py), factor it into a shared test helper. - -### F2 — nit -- Where: test/pytest/test_apps.py:355, 386 -- What: both launcher tests pass `--port 4567` on argv; the default-port path (`args.port = 5555` → `sub_port=5556`) is not exercised. -- Why: the plan only names "extend the two existing param-manager launcher tests to assert the kwargs", which is done; the default-port branch is the same one code line and the argparse default was already an int, so the risk of an uncaught regression there is negligible. -- Suggested fix: optionally add a third launcher test without `--port` asserting `sub_port=5556`; not blocking. - -## Notes - -- Tests run: `uv run pytest test/pytest/test_apps.py test/pytest/test_param_manager.py` → `31 passed in 12.61s`. Full suite: `uv run pytest` → `174 passed, 4 warnings in 69.09s` (the 4 warnings are pre-existing `PytestUnknownMarkWarning` for the `integration` mark, unrelated to this commit). -- Plan's named tests, all present and meaningful: - - `test_apps.py` — the two existing param-manager launcher tests (`test_param_manager_script_instrument_exists`, `test_param_manager_script_instrument_missing`) were extended to assert `ParameterManagerGui` is called with `sub_port=4568, sub_host="localhost"`. They would fail if the launcher regressed: the old `ParameterManagerGui(pm)` call does not match `assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost")`, and (see below) removing `type=int` makes `args.port + 1` a `TypeError` that errors the test before the assertion. - - `test_param_manager.py` — `test_add_parameter_without_initial_value_succeeds_and_broadcasts` is the named proxy test: via the `param_manager` fixture it calls `add_parameter("x")` with no `initial_value`/`unit`, then asserts exactly one `parameter-creation` broadcast on `server_port + 1` with `name="parameter_manager.x"`, `value is None`, `unit == ""`, and that `"x" in params.parameters`. I traced the broken-code path to confirm it is not vacuous: with the pre-fix `kwargs["initial_value"]` the KeyError is raised server-side after the call succeeds, wrapped in `ServerResponse(error=...)` by `executeServerInstruction`, and re-raised client-side by `BaseClient._handle_server_error` (default `raise_exceptions=True`) — so `params.add_parameter("x")` raises and the test fails; even if the exception were swallowed, no broadcast is emitted (the KeyError aborts before `_broadcastParameterChange`) and `wait_for_broadcasts` times out. The test pins the fix in both directions (call succeeds, and the exact broadcast payload). -- Right layers: launcher kwargs are checked in mocked unit tests (the established Phase-3 pattern in test_apps.py, no Qt event loop, no server); the KeyError fix is checked at the proxy layer through a live server and a real `SubClient`, matching the plan's "proxy tests use the `param_manager` fixture" convention. Names are accurate and in the plan's vocabulary. -- Coder's judgment call (`type=int` on `--port` in apps.py): consistent with the plan's scope rule. D24 requires `sub_port = args.port + 1`, which is uncomputable for a string port; before the commit only the int default `5555` worked and any `--port ` would have crashed the launcher at `args.port + 1`. The change is minimal, is pinned by the two launcher tests (they pass the string `"4567"` on argv and assert the int `sub_port=4568`), and does not touch any other launcher. `Client(port=...)` already receives ints elsewhere (conftest passes the int `server_port` fixture), so nothing downstream breaks. -- No existing tests were weakened, deleted or skipped; both launcher tests kept their original assertions and gained the kwargs check. -- Edge cases: `add_parameter` with `initial_value` present but `unit` missing (or vice versa) shares the same `kwargs.get` path and was equally broken pre-fix; the plan names only the both-missing proxy test, and the present-with-both case is already covered by the pre-existing `test_proxy_add_remove_parameter`. No gap. diff --git a/orchestration/0.5/decisions.md b/orchestration/0.5/decisions.md deleted file mode 100644 index 918a1e1..0000000 --- a/orchestration/0.5/decisions.md +++ /dev/null @@ -1,83 +0,0 @@ -# 0.5 Broadcast action constants — decisions log - -Run: run_e6f4c00ea2df. Branch: marcosfrenkel/new-param-manager. Base commit: 884558a14b1944d0443fc23fb712f5a4a63c6427. - -## Workers - -| agent id | terminal handle | current dispatch id | -|---|---|---| -| plan-checker-qwen | term_67098f1f-6389-4754-acd8-0f15458c678c | ctx_b1fe21a35b2e (task_73fdcff54804, round 0) | -| plan-checker-deepseek | term_c40aa02c-a55c-482d-9bca-b9e3d129a3f1 | ctx_183c1dd49fb5 (task_ff5b484b2eb6, round 0) | -| test-reviewer-qwen | term_6c9eb2c2-ba26-4624-b243-ebaf2d246b64 | ctx_a43dde782643 (task_ba174ae1ec81, round 0) | -| test-reviewer-deepseek | term_9ee09d9e-f13e-4a91-8d18-85fbbba3c79a | ctx_8a4f95a54af0 (task_9d8fa0f4573d, round 0) | -| reviewer-qwen | term_40f9b8ed-357a-42f1-b762-433069d06a92 | ctx_3f6cbf7c89e0 (task_7a7b36fb5f99, round 0) | -| reviewer-deepseek | term_18d1e6c4-9a03-4a7d-b81a-9e5ba1e6dd35 | ctx_4f3fa9fe4d15 (task_9707f5bdc29a, round 0) | -| coder | term_6a933bf1-6616-4ea6-8275-dd5aa36436d7 | ctx_82a780643c17 (task_c293e403e57a, first implementation) | - -## Log - -- Checkbox 0.5 set to [~]. Base 884558a. -- Coder dispatched for first implementation (task_c293e403e57a / ctx_82a780643c17). -- Coder worker_done (succeeded). Commit b3e6586 "0.5: broadcast action constants in blueprints.py, used at every literal site in server, gui and client application"; files: blueprints.py, client/application.py, gui/instruments.py, server/core.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files, nothing dirty outside orchestration/ and the plan. No permission prompts. Coder retained. -- Coder reported: monitoring/listener.py has no `parameter-` literal, so unchanged. Remaining literals after the change: blueprints.py:84-87 (definitions), log.py:157-158 (comment + log-parsing regex, not a named module, wire value unchanged), testing/dummy_instruments/generic.py:454 (test-helper default). Orchestrator confirmed with `git grep -n '"parameter-' -- src/`. -- Orchestrator tests: `uv run pytest -q` -> 174 passed, 4 warnings in 69.09s. -- Six reviewers dispatched for round 0 (target 884558a..b3e6586). -- Permission: reviewer-deepseek asked uv run pytest prefixed with a harmless sw_vers call (read-only). Allowed once. -- Permission: plan-checker-qwen asked git rev-list/diff --stat/status (read-only). Allowed once. -- Permission: plan-checker-qwen asked rg + python AST name-collision check on blueprints.py (read-only). Allowed once. -- reviewer-qwen worker_done (succeeded, approve, 1 nit: log.py regex could use the constant). Retained. -- Permission: plan-checker-qwen re-ran the AST check under uv run (read-only). Allowed once. -- test-reviewer-qwen worker_done (succeeded, changes-needed: 1 should-fix, no test pins the six constant values). Retained. -- plan-checker-qwen worker_done (succeeded, approve, 1 nit). Retained. -- Permission: reviewer-deepseek asked access to /tmp (outside the repo). REJECTED; told it its only output is its report file. -- Permission: reviewer-deepseek asked sleep 120 + ps check on its background pytest run (read-only). Allowed once. -- Permission: reviewer-deepseek asked rm of its own scratch log orchestration/0.5/round-0/_pytest_deepseek.log. Allowed once (its own scratch file). -- test-reviewer-deepseek stalled on a provider 'Upstream error'; plan-checker-deepseek degenerated into garbled output. No reports. Both nudged to resume and report. -- Permission: reviewer-deepseek asked a garbled request. REJECTED; told it to write its report and send worker_done. -- reviewer-deepseek worker_done after nudges (succeeded, approve, 1 nit). Retained. -- Permission: test-reviewer-deepseek asked a garbled request. REJECTED; told it to write its report with the file-write tool only. -- test-reviewer-deepseek wrote its report but stopped before worker_done; plan-checker-deepseek hit another provider error. Both nudged again. -- test-reviewer-deepseek worker_done after nudges (succeeded, approve, 0 findings; argues existing tests already guard the wire strings). Retained. -- plan-checker-deepseek: provider error on its Write call after deciding approve. Nudged to retry. -- plan-checker-deepseek worker_done after nudges (succeeded, approve, 2 nits). Retained. All six round-0 reports present. - -## Round 0 merge (six reports: 5 approve, test-reviewer-qwen changes-needed) - -- test-reviewer-qwen F1 (should-fix): no test pins the wire values of 5 of the 6 new constants. test-reviewer-deepseek disagrees (approve, "existing round-trip tests guard the wire strings"), but its examples (test_base.py, test_broadcaster.py:223) compare literals to literals or to the dummy helper's literal default, never to the constants; orchestrator checked with `git grep` over test/: only test_param_manager.py:117 pins a constant-driven emission. Contradiction resolved in favour of test-reviewer-qwen: the fact is confirmed and the fix is a six-line unit test. KEPT. -- reviewer-qwen F1, plan-checker-deepseek F1, plan-checker-qwen F1 (part): log.py:158 regex keeps the literal; log.py is not a named module. Not sent: nit. -- plan-checker-deepseek F2, plan-checker-qwen F1 (part): testing/dummy_instruments/generic.py:454 default arg keeps the literal; outside the named modules. Not sent: nit. -- reviewer-deepseek F1 (nit): comment says PM_* are "emitted by" Broadcaster instruments before any code does so. Not sent: nit; forward statement matches D10/D26. -- Fix list: 1 item -> fix round 1. -- Fix round 1 dispatched to the coder in its same terminal (task_230ac9f3fd20 / ctx_0d1ace03d034). -- Coder worker_done for fix round 1 (succeeded). Commit eec0c25 "0.5: fix from review round 1: pin the broadcast action constants' wire values in a unit test"; only test/pytest/test_broadcaster.py. Checks: 1 commit, prefix ok, branch unchanged, no orchestration/ files. Coder retained. -- Orchestrator tests after fix 1: test_broadcaster.py -> 13 passed in 7.43s; full suite -> 175 passed, 4 warnings in 68.94s. -- Re-review 1 dispatched to all six reviewers in their same terminals (target eec0c25): - -| agent id | round | dispatch (task) | -|---|---|---| -| plan-checker-qwen | re-review 1 | ctx_838d7323415f (task_111e6080e396) | -| plan-checker-deepseek | re-review 1 | ctx_2b5d1afc5765 (task_2b936cdef701) | -| test-reviewer-qwen | re-review 1 | ctx_1bd966a6e70d (task_c187152cfe92) | -| test-reviewer-deepseek | re-review 1 | ctx_7aa12e05533b (task_de50b48c4eff) | -| reviewer-qwen | re-review 1 | ctx_a087f9a1d688 (task_a64018eb14f2) | -| reviewer-deepseek | re-review 1 | ctx_bfd917db4c5a (task_65754717fb1e) | - -- Re-review 1: test-reviewer-qwen approve (F1 fixed, 0 new). Retained. -- Re-review 1: reviewer-qwen approve (1 new nit: docstring overstates); plan-checker-qwen approve (0 new). Both retained. -- Permission: plan-checker-deepseek asked access to a garbled path outside the repo. REJECTED; redirected to the relative report path. -- Re-review 1: reviewer-deepseek approve (0 new). Retained. -- Re-review 1: test-reviewer-deepseek approve (0 new; report at the correct path although its worker_done payload string was garbled). Retained. -- Re-review 1: plan-checker-deepseek approve (0 new). Retained. All six round-1 reports present. - -## Round 1 merge (six re-reviews, all `approve`, 0 must-fix / 0 should-fix) - -- Fix-list item 1 (constants pinning test): fixed by eec0c25; confirmed by test-reviewer-qwen and test-reviewer-deepseek (the latter now agrees the orchestrator's adjudication was sound). -- reviewer-qwen new nit: the new test's docstring overstates that the whole suite would pass on constant drift (test_param_manager.py:117 already pins parameter-creation). Not sent: nit. -- All round-0 nits acknowledged as dropped by their reviewers. -- Fix list: EMPTY. Task goes to finish. - -## Finish -- All seven workers released (Orca: state retained, processAction none) and their terminals closed. `worker-list --terminal-state reclaimable` for run_e6f4c00ea2df: 0 rows. -- Checkbox 0.5 set to [x]. - -**Summary.** Outcome: done. Commits: `b3e6586 0.5: broadcast action constants in blueprints.py, used at every literal site in server, gui and client application`, `eec0c25 0.5: fix from review round 1: pin the broadcast action constants' wire values in a unit test`. Fix rounds used: 1. Tests (orchestrator run after fix 1): `uv run pytest -q test/pytest/test_broadcaster.py` -> 13 passed in 7.43s; `uv run pytest -q` -> 175 passed, 4 warnings in 68.94s. diff --git a/orchestration/0.5/round-0/fix-list.md b/orchestration/0.5/round-0/fix-list.md deleted file mode 100644 index f568572..0000000 --- a/orchestration/0.5/round-0/fix-list.md +++ /dev/null @@ -1,6 +0,0 @@ -# 0.5 — fix list from round 0 - -1. **Add a unit test pinning the six action constants' wire values.** (test-reviewer-qwen F1, should-fix; contested by test-reviewer-deepseek, orchestrator kept it: see decisions.md.) - - Where: a small test (no server) in `test/pytest/test_broadcaster.py` (or `test/pytest/test_base.py`; pick the file whose subject fits best), importing `PARAMETER_UPDATE`, `PARAMETER_CALL`, `PARAMETER_CREATION`, `PARAMETER_DELETION`, `PM_LOCK_UPDATE`, `PM_TYPE_UPDATE` from `instrumentserver.blueprints`. - - Why: the task says "No behaviour change", i.e. the wire strings stay what they are. Every in-repo emitter and consumer now shares the constants, so a typo in a constant value would pass the whole suite while breaking external subscribers (ADR-0003: "Existing subscribers parse them unchanged"). Today only `PARAMETER_CREATION` is pinned by a test that compares a server emission to the literal (test_param_manager.py:117); test_broadcaster.py:223 asserts "parameter-update" but that emission comes from the dummy helper's literal default, not the constant; no test names "parameter-call" or "parameter-deletion"; the PM_* strings appear in no test. - - What: assert each constant equals exactly `"parameter-update"`, `"parameter-call"`, `"parameter-creation"`, `"parameter-deletion"`, `"pm-lock-update"`, `"pm-type-update"`. Test-only change; no src/ change. diff --git a/orchestration/0.5/round-0/plan-checker-deepseek.md b/orchestration/0.5/round-0/plan-checker-deepseek.md deleted file mode 100644 index 3cee425..0000000 --- a/orchestration/0.5/round-0/plan-checker-deepseek.md +++ /dev/null @@ -1,38 +0,0 @@ -# 0.5 — plan-checker-deepseek — round 0 - -Verdict: approve - -## Findings - -No must-fix or should-fix findings. - -### F1 — nit (not sent to the coder) -- Where: src/instrumentserver/log.py:158 -- What: the log-parsing regex keeps the literal "parameter-update". -- Why: the task names four modules to edit (server/core.py, gui/instruments.py, - client/application.py, monitoring/listener.py); log.py is not one of them, and the - literal is a parsing pattern, not an emitted action. Leaving it is scope-consistent. -- Suggested fix: none required. - -### F2 — nit (not sent to the coder) -- Where: src/instrumentserver/testing/dummy_instruments/generic.py:454 -- What: the dummy instrument's emit_broadcast default keeps action="parameter-update". -- Why: it is a test-helper default, not an emission site in the four named modules. - Using the constant would be cleaner but touching it would widen scope against - "Do not widen scope" (plan way-of-working rule 6). -- Suggested fix: none required. - -## Notes - -- All six constants are defined in blueprints.py with values "parameter-update", - "parameter-call", "parameter-creation", "parameter-deletion", "pm-lock-update", - "pm-type-update". These match the literals they replaced and the D26 action names. -- Replacement sites: server/core.py (PARAMETER_UPDATE, PARAMETER_CALL, - PARAMETER_CREATION, PARAMETER_DELETION), gui/instruments.py (PARAMETER_CREATION, - PARAMETER_DELETION, PARAMETER_UPDATE, PARAMETER_CALL), client/application.py - (PARAMETER_UPDATE). Wire strings unchanged, so no behaviour change. -- monitoring/listener.py contained no "parameter-" literal, so needed no edit, - consistent with the coder's and orchestrator's git grep. -- Whole suite was not re-run by me; the orchestrator confirmed no literal remains - in the four named modules via git grep, and the changes are string constants - with identical values, so behaviour is preserved. \ No newline at end of file diff --git a/orchestration/0.5/round-0/plan-checker-qwen.md b/orchestration/0.5/round-0/plan-checker-qwen.md deleted file mode 100644 index 731a311..0000000 --- a/orchestration/0.5/round-0/plan-checker-qwen.md +++ /dev/null @@ -1,35 +0,0 @@ -# 0.5 — plan-checker-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: src/instrumentserver/testing/dummy_instruments/generic.py:454, src/instrumentserver/log.py:157-158 -- What: two `parameter-` literals remain under src/, both left untouched on purpose by the coder. -- Why: task text says "use them in `server/core.py`, `gui/instruments.py`, `client/application.py`, - `monitoring/listener.py` wherever the literals appear" and these files are not on that list; - Way-of-working rule 1 is "Do not widen scope". The coder's call is consistent with the task text. - generic.py:454 is a default argument that sets an action string, so `PARAMETER_UPDATE` there - would be a one-line consistency win; the log.py regex parses log text, not the wire string. -- Suggested fix: none for 0.5; optionally adopt the constants in a later task that touches those files. - -## Notes -- Single commit b3e6586 in the range; message starts with "0.5:" per session protocol step 6. -- All six constants defined in blueprints.py; values match pre-commit literals exactly: - "parameter-update", "parameter-call", "parameter-creation", "parameter-deletion", - plus "pm-lock-update" / "pm-type-update" per D26 ("actions `pm-lock-update`, `pm-type-update`") - and D10 ("Action strings stay strings; module-level constants for all actions"). -- No behaviour change: every substitution is byte-identical (diff read line by line); - `ParameterBroadcastBluePrint` and the wire format are untouched; no name collisions in blueprints.py. -- monitoring/listener.py verified clean: no `parameter-` literal at all; its listeners filter on - `message.name` only, never on action, so the coder's report is correct. -- No `parameter-` occurrence left in any of the four named files (rg across all four: zero matches). -- PM_LOCK_UPDATE / PM_TYPE_UPDATE are defined but unused for now, as expected: their emitters - arrive in tasks 1.3 and 2.5. -- Test files keep literals (test_base.py, test_broadcaster.py, test_param_manager.py); the task - asks only for whole-suite green and those literal asserts pin the wire strings. -- Whole suite: `uv run pytest` -> `174 passed, 4 warnings in 68.60s`; warnings are pre-existing - `PytestUnknownMarkWarning` in test_apps.py, unrelated to this commit. -- Working tree has uncommitted orchestrator files (PLAN_parameter_manager_redesign.md, - orchestration/RUNS.md, orchestration/0.5/); the commit itself touches only the four source files. diff --git a/orchestration/0.5/round-0/reviewer-deepseek.md b/orchestration/0.5/round-0/reviewer-deepseek.md deleted file mode 100644 index 7e4276d..0000000 --- a/orchestration/0.5/round-0/reviewer-deepseek.md +++ /dev/null @@ -1,42 +0,0 @@ -# 0.5 — reviewer-deepseek — round 0 - -Verdict: approve - -## Findings - -No must-fix or should-fix findings. - -### F1 — nit -- Where: src/instrumentserver/blueprints.py:80-89 -- What: The comment block says "Action strings carried in - ParameterBroadcastBluePrint.action" but PM_LOCK_UPDATE / PM_TYPE_UPDATE - are not yet emitted by any code (used from Phase 1.3 / 2.5 onward). -- Why: PM_LOCK_UPDATE and PM_TYPE_UPDATE are defined but currently unused, so - flake/type checks may flag them; also the comment's claim they are "emitted - by instruments implementing the Broadcaster contract" is a forward - statement, not true on this branch yet. -- Suggested fix: Accept as-is; they are required by the task and the plan - (D10, D26) and will be consumed in later phases. - -## Notes - -Wire-string verification: every constant equals the exact literal it replaces -(parameter-update / parameter-call / parameter-creation / parameter-deletion, -plus pm-lock-update and pm-type-update from D10/D26). All replacements in -server/core.py, gui/instruments.py and client/application.py are 1:1 -literal-to-constant swaps inside existing branches; no logic or ordering -changed. No behaviour change. - -grep '"parameter-' across src/ finds literals only in blueprints.py (the new -definitions) plus two sites the coder intentionally left untouched: -log.py:157-158 (a comment and a log-parsing regex `parameter-update:`) and -testing/dummy_instruments/generic.py:454 (a test-helper default argument -`action="parameter-update"`). The task names exactly four modules (server/core, -gui/instruments, client/application, monitoring/listener); log.py and -testing/ are not among them, and the plan's way-of-working rule 6 ("Do not -widen scope") supports leaving them alone. The judgement is correct. -monitoring/listener.py has no literal at all, so nothing to change there. - -Tests: `uv run pytest -q` -> 174 passed, 4 warnings in 68.44s. The 4 warnings -are pre-existing PytestUnknownMarkWarning for @pytest.mark.integration in -test_apps.py, unrelated to this commit. \ No newline at end of file diff --git a/orchestration/0.5/round-0/reviewer-qwen.md b/orchestration/0.5/round-0/reviewer-qwen.md deleted file mode 100644 index 0b2e077..0000000 --- a/orchestration/0.5/round-0/reviewer-qwen.md +++ /dev/null @@ -1,30 +0,0 @@ -# 0.5 — reviewer-qwen — round 0 - -Verdict: approve - -## Findings - -### F1 — nit -- Where: src/instrumentserver/log.py:158 -- What: The log-parsing regex still hardcodes the wire string ("parameter-update") instead of interpolating PARAMETER_UPDATE. -- Why: It is coupled to the client's log format; if the action string ever changes this regex would silently stop matching. The coder left it on purpose: log.py is not one of the task's four named files, and it parses log text rather than emitting or comparing the action, so this is a preference, not a task violation. -- Suggested fix: None required. Optionally `re.compile(rf"{PARAMETER_UPDATE}:\s*...")`. - -## Notes -- Verified no behaviour change: the four constants equal the former literals exactly; - `pm-lock-update` / `pm-type-update` had no literal anywhere in the parent commit - (`git grep` on 884558a finds none), so the two new constants are purely additive - and match D26. -- `git grep -n "parameter-" b3e6586` over the four named files returns nothing: - server/core.py, gui/instruments.py, client/application.py are fully converted. - monitoring/listener.py genuinely contains no action literal (its listeners filter - on `message.name`, not `message.action`); the coder's report is accurate. -- Two remaining literals judged correctly left: log.py:157-158 (comment + regex, - see F1) and testing/dummy_instruments/generic.py:454 (default argument of the - DummyBroadcasterInstrument test helper; not a named file, equivalent either way). -- Imports are alphabetical and consistent with existing import blocks; no new - circular dependency (blueprints only imports helpers). -- Remaining `.action` uses in src/ are log f-strings (base.py:137, server/core.py:612) - and serialization (blueprints.py:378,392); none compare literals, none need change. -- Tests: `uv run pytest` → `174 passed, 4 warnings in 68.92s` (warnings are - pre-existing unknown-mark warnings in test_apps.py, unrelated). diff --git a/orchestration/0.5/round-0/test-reviewer-deepseek.md b/orchestration/0.5/round-0/test-reviewer-deepseek.md deleted file mode 100644 index 1461215..0000000 --- a/orchestration/0.5/round-0/test-reviewer-deepseek.md +++ /dev/null @@ -1,33 +0,0 @@ -# 0.5 - test-reviewer-deepseek - round 0 - -Verdict: approve - -## Findings - -None. - -The commit is a pure constants refactor with no new behaviour and no named -test beyond "whole suite green". The existing broadcast tests remain -meaningful and do guard the wire strings: - -- test/pytest/test_base.py:33,41,49,101,149 build and decode - ParameterBroadcastBluePrint with action "parameter-update" and assert the - decoded string equals "parameter-update". This round-trip would fail if any - constant value silently changed. It passes. -- test/pytest/test_param_manager.py:117 asserts bp.action == "parameter-creation" - on the server emission that now uses PARAMETER_CREATION. Passes. -- test/pytest/test_broadcaster.py:223 asserts a received action == - "parameter-update". Passes. - -Wire strings verified identical to the originals in 884558a (git show grep): -parameter-update / parameter-call / parameter-creation / parameter-deletion -all match the new constants; PM_LOCK_UPDATE and PM_TYPE_UPDATE are -"pm-lock-update" / "pm-type-update" per D26. monitoring/listener.py has no -`parameter-` literal, matching the coder's report. The two deliberately -untouched literals (log.py:157-158 regex, and the -testing/dummy_instruments/generic.py:454 default arg) are outside the four -named modules and correct to leave. No test weakened, deleted or skipped. - -## Notes - -Tests run: `uv run pytest -q` -> 174 passed, 4 warnings in 68.39s. \ No newline at end of file diff --git a/orchestration/0.5/round-0/test-reviewer-qwen.md b/orchestration/0.5/round-0/test-reviewer-qwen.md deleted file mode 100644 index 3ec80d1..0000000 --- a/orchestration/0.5/round-0/test-reviewer-qwen.md +++ /dev/null @@ -1,48 +0,0 @@ -# 0.5 — test-reviewer-qwen — round 0 - -Verdict: changes-needed - -## Findings - -### F1 — should-fix — no test pins the wire-string values of the new constants -- Where: src/instrumentserver/blueprints.py:84-89 (constants); suggested home for the missing test: test/pytest/test_broadcaster.py -- What: only 1 of the 6 new action constants is pinned by any test that compares a server emission against a hardcoded wire string. -- Why: the task's core claim is "No behaviour change" — the wire strings stay what they are. - Since every in-repo emitter and consumer now shares the constant, a typo in a constant - value passes the whole suite while breaking external subscribers (ADR-0003: "Existing - subscribers parse them unchanged"). - Pinned today: PARAMETER_CREATION, via test/pytest/test_param_manager.py:117 - (server emits through the constant, test asserts the literal "parameter-creation"). - Not pinned: PARAMETER_UPDATE, PARAMETER_CALL, PARAMETER_DELETION (test_broadcaster.py:223 - asserts the literal "parameter-update" but that emission comes from the dummy helper's - literal default at testing/dummy_instruments/generic.py:454, not from the constant; no - test asserts "parameter-call" or "parameter-deletion" at all), and PM_LOCK_UPDATE / - PM_TYPE_UPDATE (unused in src until Phases 1-3, no test references the strings; their - names are fixed by D26). -- Suggested fix: add a small unit test (no server) that imports the six constants from - instrumentserver.blueprints and asserts each equals exactly "parameter-update", - "parameter-call", "parameter-creation", "parameter-deletion", "pm-lock-update", - "pm-type-update". Expected result: any constant value drift fails the test. - -## Notes -- Named tests for this task: "whole suite green" (task names no specific test file); - ran the full suite. Summary line: `174 passed, 4 warnings in 68.55s (0:01:08)`. - The 4 warnings are pre-existing PytestUnknownMarkWarning for pytest.mark.integration in - test/pytest/test_apps.py; not introduced by this commit. -- The commit adds no new or changed tests and weakens, deletes or skips none. -- No-behaviour-change verified on the wire: each of the four in-use constants equals its - previous literal exactly (parameter-update, parameter-call, parameter-creation, - parameter-deletion); the diff is purely literal-to-constant swaps with no logic edits. - PM_LOCK_UPDATE / PM_TYPE_UPDATE match D26 and are unused for now, as intended. -- monitoring/listener.py: confirmed zero `parameter-` literals and no `action` handling; - the coder's claim holds, so nothing to use there. -- Deliberately untouched literals, judged against the task text: log.py:157-158 is a - comment plus a regex that parses client-station log lines, and log.py is not one of the - four named modules; testing/dummy_instruments/generic.py:454 is a test-helper default - argument, also outside the named-file list. Plan scope rule "Do not widen scope" supports - leaving both. From the test side, keeping generic.py:454 a literal is actually beneficial: - it keeps test_broadcaster.py:223 an independent literal check of that wire string; using - the constant there would make the assertion circular. No finding. -- test/pytest/test_base.py and test_broadcaster.py make_bp() use hardcoded - "parameter-update" literals when building blueprints; that is correct and independent, - not a weakness. diff --git a/orchestration/0.5/round-1/fix-list.md b/orchestration/0.5/round-1/fix-list.md deleted file mode 100644 index c89bb5b..0000000 --- a/orchestration/0.5/round-1/fix-list.md +++ /dev/null @@ -1,3 +0,0 @@ -# 0.5 — fix list from round 1 - -Empty. All six re-reviews approve with no must-fix or should-fix findings. diff --git a/orchestration/0.5/round-1/plan-checker-deepseek.md b/orchestration/0.5/round-1/plan-checker-deepseek.md deleted file mode 100644 index ff966af..0000000 --- a/orchestration/0.5/round-1/plan-checker-deepseek.md +++ /dev/null @@ -1,28 +0,0 @@ -# 0.5 — plan-checker-deepseek — round 1 - -Verdict: approve - -## Previous findings - -- F1 (nit) log.py:158 regex keeps the literal: dropped by orchestrator - (decisions.md "Not sent: nit"). -- F2 (nit) dummy_instruments/generic.py:454 default keeps the literal: - dropped by orchestrator (decisions.md "Not sent: nit"). - -## Findings - -No new must-fix or should-fix findings. - -## Notes - -- Fix commit eec0c25 adds test_broadcast_action_constants_pin_the_wire_strings - to test/pytest/test_broadcaster.py, asserting each of the six constants - against its exact wire string. No src/ change. -- It is a no-server unit test, consistent with "Tests per layer" (plan - way-of-working rule 5). -- The pinned values match what the constants held in round 0 and the D26 - action names, so wire strings are still unchanged: no behaviour change. -- Test-only change; nothing in my focus area (scope, vocabulary, wire - strings, plan rules) is broken or weakened. -- Full suite: 175 passed (run by the orchestrator); fix adds one passing - test, so 175 includes it. \ No newline at end of file diff --git a/orchestration/0.5/round-1/plan-checker-qwen.md b/orchestration/0.5/round-1/plan-checker-qwen.md deleted file mode 100644 index daef63d..0000000 --- a/orchestration/0.5/round-1/plan-checker-qwen.md +++ /dev/null @@ -1,31 +0,0 @@ -# 0.5 — plan-checker-qwen — round 1 - -Verdict: approve - -## Previous findings - -- F1 (nit, log.py:157-158 + generic.py:454 literals left untouched): dropped by orchestrator - (decisions.md "Round 0 merge": both parts "Not sent: nit"). Not sent to the coder, so still - present in the tree, as expected. No action needed. - -## Fix commit check - -- eec0c25 adds test_broadcast_action_constants_pin_the_wire_strings to - test/pytest/test_broadcaster.py; no src/ change. -- The test was requested by the fix list item 1 (test-reviewer-qwen F1, kept by orchestrator); - it asserts all six constants equal exactly "parameter-update", "parameter-call", - "parameter-creation", "parameter-deletion", "pm-lock-update", "pm-type-update" — the wire - values from D26 and ADR-0003 ("Existing subscribers parse them unchanged"). -- It can fail: literal-equality asserts, not a tautology. -- No behaviour change: test-only commit; wire strings and API untouched. -- Plan rules: commit message starts with "0.5:", separate fix-round commit, glossary terms - used ("Broadcast", "subscribers") in docstring and section comment. -- Placement: test_broadcaster.py, which the fix list named as an acceptable file. - -## New findings - -- None. - -## Notes -- `uv run pytest test/pytest/test_broadcaster.py` -> 13 passed in 7.47s (12 before the fix). -- Orchestrator ran the full suite after the fix: 175 passed (decisions.md). diff --git a/orchestration/0.5/round-1/reviewer-deepseek.md b/orchestration/0.5/round-1/reviewer-deepseek.md deleted file mode 100644 index 374a5ce..0000000 --- a/orchestration/0.5/round-1/reviewer-deepseek.md +++ /dev/null @@ -1,31 +0,0 @@ -# 0.5 — reviewer-deepseek — round 1 - -Verdict: approve - -## Previous findings - -- F1 (nit, blueprints.py:80-89 comment is a forward statement about the PM_* - constants): dropped by the orchestrator (decisions.md "Round 0 merge": "Not - sent: nit; forward statement matches D10/D26"). No fix required. - -## Findings - -No must-fix or should-fix findings in this round. - -## Notes - -Fix commit eec0c25 adds only a no-server unit test -`test_broadcast_action_constants_pin_the_wire_strings` to -test/pytest/test_broadcaster.py; no src/ change (diff confirmations: only -that file changed, and the b3e6586 src/ edits are untouched). - -The new test imports all six constants from `instrumentserver.blueprints` -and asserts each equals its exact wire string. Values match the pre-fix -literals and D10/D26 (pm-lock-update, pm-type-update). Correct values, -correct import path, sensible placement in the Broadcaster contract test -file, no fixtures/server needed. It strengthens rather than weakens my -focus area: a typo in any constant would now fail this test, closing the -gap that the fix list described. - -Test run: `uv run pytest -q test/pytest/test_broadcaster.py` -> 13 passed in -7.45s. No new findings caused by the fix. \ No newline at end of file diff --git a/orchestration/0.5/round-1/reviewer-qwen.md b/orchestration/0.5/round-1/reviewer-qwen.md deleted file mode 100644 index 03c0036..0000000 --- a/orchestration/0.5/round-1/reviewer-qwen.md +++ /dev/null @@ -1,26 +0,0 @@ -# 0.5 — reviewer-qwen — round 1 - -Verdict: approve - -## Previous findings -- F1 (nit, log.py:158 regex hardcodes the wire string): dropped by the orchestrator - (decisions.md "Round 0 merge": "Not sent: nit"). Not fixed, as intended. - -## New findings - -### F2 — nit -- Where: test/pytest/test_broadcaster.py:139-141 (new test docstring) -- What: The docstring claims "the whole suite would pass even if a constant's value - drifted", but test_param_manager.py:117 already compares a server-driven emission - to the literal "parameter-creation", so that one constant is pinned by an existing test. -- Why: The claim slightly overstates; five of six constants are unpinned, not all six. -- Suggested fix: Reword, e.g. "most constants' values would drift undetected". - -## Notes -- Fix commit eec0c25 touches only test/pytest/test_broadcaster.py (no src/ change), - so nothing in my focus area broke or weakened; no behaviour change. -- The new test imports all six constants, asserts each exact wire value, needs no - server fixture, and can genuinely fail; it directly implements fix-list item 1. -- Imports alphabetical and consistent with the file's existing import blocks. -- Tests: `uv run pytest test/pytest/test_broadcaster.py` -> 13 passed in 7.47s. - Orchestrator ran the full suite after the fix: 175 passed, 4 warnings. diff --git a/orchestration/0.5/round-1/test-reviewer-deepseek.md b/orchestration/0.5/round-1/test-reviewer-deepseek.md deleted file mode 100644 index 4127a7c..0000000 --- a/orchestration/0.5/round-1/test-reviewer-deepseek.md +++ /dev/null @@ -1,38 +0,0 @@ -# 0.5 — test-reviewer-deepseek — round 1 - -Verdict: approve - -## Findings - -None. - -## Previous findings - -My round-0 report was approve with zero formal findings (no must-fix, -should-fix, or nit sent to the coder). The single fix-list item — add a -no-server unit test pinning the six constant wire values — came from -test-reviewer-qwen (should-fix), not from me. I had contested it in round 0 -("existing round-trip tests already guard the wire strings"); the orchestrator -checked the facts (only test_param_manager.py:117 pins a constant-driven -emission; my cited examples compare literals to literals or to the dummy -helper's literal default) and kept it. That adjudication is sound, so there is -nothing for me to mark fixed/not-fixed/dropped beyond noting I had no findings -of my own in round 0. - -## Fix commit eec0c25 (test/pytest/test_broadcaster.py only) - -The added test `test_broadcast_action_constants_pin_the_wire_strings` asserts -each of the six constants equals exactly its wire string: -parameter-update / parameter-call / parameter-creation / parameter-deletion / -pm-lock-update / pm-type-update. It is a no-server unit test at the right -layer, correctly named in the plan's vocabulary (D26 names), and it would fail -if any constant value drifted. This closes the gap that justified fix round 1. -No src/ change. - -Nothing existing was weakened, deleted or skipped; the file grew from 12 to 13 -tests and all prior tests are unchanged. - -## Notes - -Tests run: `uv run pytest test/pytest/test_broadcaster.py -q` -> 13 passed in -7.47s. Orchestrator reports full suite 175 passed. \ No newline at end of file diff --git a/orchestration/0.5/round-1/test-reviewer-qwen.md b/orchestration/0.5/round-1/test-reviewer-qwen.md deleted file mode 100644 index b276975..0000000 --- a/orchestration/0.5/round-1/test-reviewer-qwen.md +++ /dev/null @@ -1,37 +0,0 @@ -# 0.5 — test-reviewer-qwen — round 1 - -Verdict: approve - -## Previous findings - -- F1 (should-fix, no test pins the wire values of the new constants): FIXED. - The fix commit eec0c25 adds test_broadcast_action_constants_pin_the_wire_strings - to test/pytest/test_broadcaster.py (the file I suggested). It is a no-server unit - test that asserts each of the six constants equals exactly "parameter-update", - "parameter-call", "parameter-creation", "parameter-deletion", "pm-lock-update", - "pm-type-update". Any value drift now fails the suite; the literals are independent - of the constants (not circular). No src/ change in the fix commit. -- No other findings of mine from round 0. The log.py / generic.py omissions were - notes in my report, not findings; decisions.md confirms they were dropped as - nits by other reviewers, which matches my round-0 position (out of the task's - named files, and the generic.py literal keeps test_broadcaster.py:223 an - independent check). - -## Did the fix weaken or break anything - -- No. The commit is purely additive: one import block and one new test function - in test/pytest/test_broadcaster.py. No existing test modified, no src/ touched, - no test skipped or deleted. - -## New findings - -- None. The new test is at the right layer (unit, no server), named in the plan's - vocabulary (Broadcast, action, wire strings), and would fail if the feature were - broken (a drifted constant value). - -## Notes -- `uv run pytest test/pytest/test_broadcaster.py` -> 13 passed in 7.46s - (12 previous + 1 new). -- Orchestrator ran the full suite after the fix: 175 passed, 4 warnings - (pre-existing PytestUnknownMarkWarning in test_apps.py). -- Counts: 0 must-fix, 0 should-fix, 0 nit. diff --git a/orchestration/RUNS.md b/orchestration/RUNS.md deleted file mode 100644 index 1026076..0000000 --- a/orchestration/RUNS.md +++ /dev/null @@ -1,66 +0,0 @@ -# Orchestration runs - -## Run 2026-09-23 — run_caa796369a9e - -- Plan: PLAN_parameter_manager_redesign.md -- Tasks: 0.1 (--only 0.1, pilot) -- Branch: marcosfrenkel/new-param-manager -- Starting commit: 447c7f71542e443410684849084ae230cbc8ecfc - -### Report - -| Task | Outcome | Commits | Fix rounds | Final tests | -|---|---|---|---|---| -| 0.1 | done | `46e34cd 0.1: split ParameterGroup out of ParameterManager` | 0 | named file 12 passed; full suite 161 passed, 4 warnings | - -- Six reviewers, all `approve`. One should-fix (test-reviewer-qwen) dropped as factually wrong; five nits not sent. Details: `orchestration/0.1/decisions.md`. -- Open questions for the user: none. -- Workers still alive: none. Stopped because `--only 0.1` was given. -- Permission prompts handled: 5 allowed (all read-only), 1 rejected (reviewer-deepseek tried `git worktree add /tmp/...`). -- Process notes for the next run: (1) reviewer-deepseek's turn ended once without sending worker_done and needed a terminal nudge; (2) three reviewers running `uv run pytest` at the same time collided on fixed ports 5555/5599 and each saw one spurious failure. Consider telling reviewers to run only the task's named test file, or stagger full-suite runs. (3) opencode's bash allowlist misses read-only commands chained with `&&` or prefixed with `cd ... &&`, which caused most prompts. - -## Run 2026-09-23 — run_da269441b6ac - -- Plan: PLAN_parameter_manager_redesign.md -- Tasks: 0.0, 0.2, 0.3, 0.4, 0.5 (rest of Phase 0; 0.1 already done) -- Branch: marcosfrenkel/new-param-manager -- Starting commit: dcac611241cfbf698885d126a67e8fe11332ffc0 - -### Report - -| Task | Outcome | Commits | Fix rounds | Final tests | -|---|---|---|---|---| -| 0.0 | done | `71aa9af 0.0: per-run test ports via session-scoped server_port fixture` | 0 | full suite 161 passed, 4 warnings (two concurrent runs both green) | -| 0.2 | done | `8d04b42 0.2: add Broadcaster mixin and mix it into ParameterManager`, `693d4e7 0.2: fix from review round 1: pin duplicate-sink delivery semantics in a unit test` | 1 | named file 9 passed; full suite 170 passed, 4 warnings | - -- Stopped after 0.2 at the user's request (user asked mid-run not to start 0.3). Remaining Phase 0 tasks: 0.3, 0.4, 0.5. -- Open questions for the user: (1) plan-checker-qwen: the plan's "Testing" section still says GUI tests use "own server on a fixed port >= 5600", which D27 / task 0.0 made stale; the plan text should be updated. (2) reviewer-qwen note for 0.3: once the Server registers itself as a sink, `ParameterManager.broadcast` (a public method) becomes callable over the wire, so any client could inject arbitrary Broadcasts; consider whether 0.3 should address that or whether it is accepted. -- Workers still alive: none. -- Permission prompts: ~45 handled; all read-only or coder-allowed edits/commits allowed once, 3 rejected (reviewer-deepseek asked for ~/.agents/roles and a garbled path outside the repo; plan-checker-deepseek tried to write its report via a python heredoc whose target was not visible). -- Process notes: (1) deepseek reviewers stalled three times (one garbled-output degeneration, one provider "Upstream error", one idle after concluding); a terminal nudge recovered each. (2) The 0.0 fixture removed the port collisions seen in the pilot run; reviewers ran the suite in parallel with no spurious failures. (3) Nits not sent but worth folding into a later task touching conftest.py: the server_port docstring's "outside the OS ephemeral range" claim is false on Linux. - -## Run 2026-09-23 — run_e6f4c00ea2df - -- Plan: PLAN_parameter_manager_redesign.md -- Tasks: 0.3, 0.4, 0.5 (--from 0.3; rest of Phase 0) -- Branch: marcosfrenkel/new-param-manager -- Starting commit: 0fbbddf9ba0410fbbd429f5f348067726591d4d8 - -### Report - -| Task | Outcome | Commits | Fix rounds | Final tests | -|---|---|---|---|---| -| 0.3 | done | `04c4cbc 0.3: server registers itself as a broadcast sink on Broadcaster instruments`, `5167241 0.3: fix from review round 1: test the config-load sink registration path` | 1 | named file 12 passed; full suite 173 passed, 4 warnings | -| 0.4 | done | `56ece34 0.4: fix latent KeyError in parameter-creation broadcast and pass broadcast port to the parameter manager GUI launcher` | 0 | named files 31 passed; full suite 174 passed, 4 warnings | -| 0.5 | done | `b3e6586 0.5: broadcast action constants in blueprints.py, used at every literal site in server, gui and client application`, `eec0c25 0.5: fix from review round 1: pin the broadcast action constants' wire values in a unit test` | 1 | named file 13 passed; full suite 175 passed, 4 warnings | - -- Phase 0 is complete. Stopped at the end of the phase (rule 7). Next open task: 1.1 `ManagedParameter`. -- Open questions / notes for the user (none block Phase 1): - 1. `_runInitScript` (server/core.py, run from `startServer` after `__init__`) can add instruments to the Station after the `__init__` sink-registration loop; those instruments would get no Broadcaster sink. The plan lists only two entry points, so 0.3 followed the plan. Decide whether the init-script path needs registration (small follow-up task) or is accepted. (plan-checker-qwen, reviewer-qwen, 0.3) - 2. Once the Server registers as a sink, the mixin's public `broadcast` / `add_broadcast_sink` / `remove_broadcast_sink` are wire-callable on any Broadcaster proxy. Inert in practice (callables do not survive JSON; a malformed remote `broadcast(dict)` hits the logged sink-error path), but a client can inject a Broadcast. A 0.2 design consequence; accept or add a note. (reviewer-qwen, 0.2 and 0.3) - 3. 0.4: the coder added `type=int` to the launcher's `--port` argparse argument so the plan's `args.port + 1` works; all six reviewers judged it in scope. Recorded here because it goes slightly beyond D24's literal text. - 4. Carried over from the previous run: the plan's Testing paragraph and the `test_gui_navigation.py` fact line still mention fixed ports, made stale by D27 / task 0.0. -- Nits not sent, worth folding into a later task: `capture_broadcasts`/`wait_for_broadcasts` are now duplicated in test_broadcaster.py and test_param_manager.py (consolidate into conftest.py when 1.3 / 2.5 need a third copy); log.py:158 regex and testing/dummy_instruments/generic.py:454 keep literal action strings. -- Workers still alive: none. -- Permission prompts: ~20 handled; all read-only or reviewers' own files allowed once; 6 rejected (garbled paths or commands from deepseek reviewers, one /tmp access). -- Process notes: (1) deepseek reviewers stalled 11 times across the three tasks (provider "Upstream error" or garbled-output degeneration), each recovered by a terminal nudge; consider a different model for the deepseek slots. (2) Orchestrator shell pitfalls found and fixed mid-run: `orca` reads stdin inside `while read` loops (use ` Date: Thu, 24 Sep 2026 10:35:55 -0500 Subject: [PATCH 021/107] Orchestration: replace deepseek reviewers with glm-5.3-flash reviewers The deepseek-v4-flash reviewers stalled 11 times across 0.3-0.5 (provider upstream errors, garbled output). Their three slots are now reviewer-glm, test-reviewer-glm and plan-checker-glm on lumen/glm-5.3-flash. Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/roles/ROSTER.md | 6 +++--- opencode.json | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/roles/ROSTER.md b/.agents/roles/ROSTER.md index 3b8a4ef..d30a78c 100644 --- a/.agents/roles/ROSTER.md +++ b/.agents/roles/ROSTER.md @@ -10,11 +10,11 @@ instructions and work with any coding agent. | Id | Role file | Runner | Model | Launch command | Role file loaded by runner? | |---|---|---|---|---|---| | `coder` | `coder.md` | opencode | lumen/glm-5.3-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent coder` | yes | -| `reviewer-deepseek` | `reviewer.md` | opencode | lumen/deepseek-v4-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent reviewer-deepseek` | yes | +| `reviewer-glm` | `reviewer.md` | opencode | lumen/glm-5.3-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent reviewer-glm` | yes | | `reviewer-qwen` | `reviewer.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent reviewer-qwen` | yes | -| `test-reviewer-deepseek` | `test-reviewer.md` | opencode | lumen/deepseek-v4-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent test-reviewer-deepseek` | yes | +| `test-reviewer-glm` | `test-reviewer.md` | opencode | lumen/glm-5.3-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent test-reviewer-glm` | yes | | `test-reviewer-qwen` | `test-reviewer.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent test-reviewer-qwen` | yes | -| `plan-checker-deepseek` | `plan-checker.md` | opencode | lumen/deepseek-v4-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent plan-checker-deepseek` | yes | +| `plan-checker-glm` | `plan-checker.md` | opencode | lumen/glm-5.3-flash | `PYTHONDONTWRITEBYTECODE=1 opencode --agent plan-checker-glm` | yes | | `plan-checker-qwen` | `plan-checker.md` | opencode | lumen/qwen3.8-27b | `PYTHONDONTWRITEBYTECODE=1 opencode --agent plan-checker-qwen` | yes | | `historian` | `historian.md` | claude | opus | `.agents/roles/bin/historian-claude.sh` | yes | diff --git a/opencode.json b/opencode.json index b16b9f7..8cf3fcf 100644 --- a/opencode.json +++ b/opencode.json @@ -89,10 +89,10 @@ "external_directory": "ask" } }, - "reviewer-deepseek": { + "reviewer-glm": { "description": "General code review of one plan task's commits. Read-only. Role: .agents/roles/reviewer.md", "mode": "primary", - "model": "lumen/deepseek-v4-flash", + "model": "lumen/glm-5.3-flash", "prompt": "{file:./.agents/roles/reviewer.md}", "permission": { "read": "allow", @@ -275,10 +275,10 @@ "external_directory": "ask" } }, - "test-reviewer-deepseek": { + "test-reviewer-glm": { "description": "Reviews whether the tests prove the task and what is untested. Read-only. Role: .agents/roles/test-reviewer.md", "mode": "primary", - "model": "lumen/deepseek-v4-flash", + "model": "lumen/glm-5.3-flash", "prompt": "{file:./.agents/roles/test-reviewer.md}", "permission": { "read": "allow", @@ -461,10 +461,10 @@ "external_directory": "ask" } }, - "plan-checker-deepseek": { + "plan-checker-glm": { "description": "Checks commits against the plan, glossary, decisions and ADRs. Read-only. Role: .agents/roles/plan-checker.md", "mode": "primary", - "model": "lumen/deepseek-v4-flash", + "model": "lumen/glm-5.3-flash", "prompt": "{file:./.agents/roles/plan-checker.md}", "permission": { "read": "allow", From 0f58c83780da13bc73b03e3c8ad34a7a22b3b03b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 10:52:54 -0500 Subject: [PATCH 022/107] 1.1: add ManagedParameter with pull-based Lock support and PMLockBluePrint --- src/instrumentserver/blueprints.py | 18 ++++ src/instrumentserver/params.py | 111 +++++++++++++++++++++++- test/pytest/test_pm_locks.py | 135 +++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 test/pytest/test_pm_locks.py diff --git a/src/instrumentserver/blueprints.py b/src/instrumentserver/blueprints.py index 08c0012..7eb3a2a 100644 --- a/src/instrumentserver/blueprints.py +++ b/src/instrumentserver/blueprints.py @@ -399,11 +399,29 @@ def toJson(self) -> Dict[str, Any]: return bluePrintToDict(self) +@dataclass +class PMLockBluePrint: + """Blueprint of a Lock of the Parameter Manager. + + Carries the full name of the Target the Follower's Lock points at and + whether the Lock is currently locked. Sent as the value of + ``pm-lock-update`` Broadcasts and returned by the Lock API. + """ + + target: str + locked: bool + _class_type: str = "PMLockBluePrint" + + def toJson(self) -> Dict[str, Any]: + return bluePrintToDict(self) + + BluePrintType = Union[ ParameterBluePrint, MethodBluePrint, InstrumentModuleBluePrint, ParameterBroadcastBluePrint, + PMLockBluePrint, ] diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 1727e23..fd855d0 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -1,9 +1,11 @@ import json import logging import os +from collections.abc import Sequence from enum import Enum, auto, unique +from functools import wraps from pathlib import Path -from typing import Any, Dict, List, Union +from typing import Any, Callable, Dict, List, Union from qcodes import Parameter, validators from qcodes.instrument import InstrumentBase @@ -11,6 +13,7 @@ from . import serialize from .base import Broadcaster +from .blueprints import PMLockBluePrint logger = logging.getLogger(__name__) @@ -57,6 +60,91 @@ def paramTypeFromName(name: str) -> Union[ParameterTypes, None]: return None +class ManagedParameter(Parameter): + """ + A parameter that can carry a Lock naming another parameter as its Target. + + While the Lock is locked, the parameter answers ``get`` with the Target's + value and refuses ``set`` with a ``ValueError`` naming the Target: values + are pulled on get, nothing is ever pushed into a Follower (ADR-0002). + Locking never touches the own cached value, so unlocking exposes the own + value again. With no Lock, or with a Lock that is present but unlocked, + the parameter behaves like a plain ``Parameter``; an unlocked Lock only + remembers its Target. + """ + + def __init__(self, name: str, **kwargs: Any) -> None: + # The Lock state must exist before ``super().__init__``: creating the + # parameter with an ``initial_value`` already runs ``set_raw``. + self.lock: PMLockBluePrint | None = None + self._target: ParameterBase | None = None + super().__init__(name, **kwargs) + + @property + def locked(self) -> bool: + """Whether a Lock is present and currently locked.""" + return self.lock is not None and self.lock.locked + + def _locked_target(self) -> ParameterBase: + """The Target parameter object of a locked Lock.""" + assert self._target is not None, "a locked Lock has no Target" + return self._target + + def own_value(self) -> Any: + """Return the own cached value, whatever state the Lock is in.""" + return self.cache.get(get_if_invalid=False) + + def get_raw(self) -> Any: + """Answer with the Target's value while locked, the own cached value + otherwise.""" + if self.locked: + return self._locked_target().get() + return self.cache.raw_value + + def set_raw(self, value: Any) -> None: + """Store the value while not locked; while locked, refuse with a + ``ValueError`` naming the Target.""" + if self.locked: + raise ValueError( + f"{self.full_name} is locked to {self._locked_target().full_name}" + ) + self.cache._set_from_raw_value(value) + + def _wrap_get(self, get_function: Callable[..., Any]) -> Callable[..., Any]: + """Wrap ``get_raw`` so that a locked get answers with the Target's + value without recording it in the own cache. + + qcodes' get wrapper writes every answered value into the parameter's + cache; for a Follower that would destroy the own value that unlocking + must expose again (ADR-0002). + """ + plain_get = super()._wrap_get(get_function) + + @wraps(get_function) + def get_wrapper(*args: Any, **kwargs: Any) -> Any: + if self.locked: + return self._locked_target().get() + return plain_get(*args, **kwargs) + + return get_wrapper + + def snapshot_base( + self, + update: bool | None = True, + params_to_skip_update: Sequence[str] | None = None, + ) -> Dict[str, Any]: + """Snapshot with a ``lock`` entry while a Lock is present; while + locked, the reported ``value`` is the Target's value.""" + snap = super().snapshot_base( + update=update, params_to_skip_update=params_to_skip_update + ) + if self.lock is not None: + if self.locked: + snap["value"] = self._locked_target().get() + snap["lock"] = {"target": self.lock.target, "locked": self.lock.locked} + return snap + + class ParameterGroup(InstrumentBase): """ A Parameter Group: a plain container of parameters and nested Parameter @@ -132,11 +220,11 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] :param kw: Any keyword arguments will be passed on to qcodes.Instrument.add_parameter, except: - ``set_cmd`` is always set to ``None`` - - ``parameter_class`` is ``qcodes.Parameter`` + - ``parameter_class`` defaults to ``qcodes.Parameter`` - ``vals`` defaults to ``qcodes.utils.validators.Anything()``. :return: None. """ - kw["parameter_class"] = Parameter + kw.setdefault("parameter_class", Parameter) if "vals" not in kw: kw["vals"] = validators.Anything() kw["set_cmd"] = None @@ -254,6 +342,23 @@ def workingDirectory(self, path: Union[str, Path]) -> None: def getWorkingDirectory(self): # type: ignore[no-untyped-def] return self.workingDirectory + def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] + """Add a parameter, created as a :class:`ManagedParameter`. + + Same dotted-name semantics as :meth:`ParameterGroup.add_parameter`, + which this method calls; only the parameter class differs, so that + the Parameter Manager's parameters can carry a Lock. + + :param name: Name of the parameter; see + :meth:`ParameterGroup.add_parameter`. + :param kw: Any keyword arguments will be passed on to + qcodes.Instrument.add_parameter, as in + :meth:`ParameterGroup.add_parameter`. + :return: None. + """ + kw["parameter_class"] = ManagedParameter + super().add_parameter(name, **kw) + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py new file mode 100644 index 0000000..7b7bb02 --- /dev/null +++ b/test/pytest/test_pm_locks.py @@ -0,0 +1,135 @@ +"""Unit tests for ``ManagedParameter`` (plan task 1.1). + +Two standalone ManagedParameters (a Target and a Follower) with no +Parameter Manager involved. The Lock is wired directly on the parameter +objects: the Lock API on the Parameter Manager is task 1.2, its Broadcasts +task 1.3. +""" + +import re + +import pytest + +from instrumentserver.blueprints import PMLockBluePrint +from instrumentserver.params import ManagedParameter, ParameterManager + + +def make_target_and_follower(): + """A Target and a Follower, both standalone ManagedParameters.""" + target = ManagedParameter("target", set_cmd=None, initial_value=11, unit="V") + follower = ManagedParameter("follower", set_cmd=None, initial_value=22, unit="V") + return target, follower + + +def lock_follower(follower, target, locked=True): + """Attach a Lock to the Follower directly (no manager: task 1.2 adds + the Lock API).""" + follower._target = target + follower.lock = PMLockBluePrint(target=target.full_name, locked=locked) + + +def test_get_redirects_to_target_while_locked(): + target, follower = make_target_and_follower() + assert follower.get() == 22 + + lock_follower(follower, target) + assert follower.locked + assert follower.get() == 11 + + # pull on get: changing the Target is enough, nothing is pushed + target.set(33) + assert follower.get() == 33 + + +def test_set_while_locked_raises_and_names_the_target(): + target, follower = make_target_and_follower() + lock_follower(follower, target) + + with pytest.raises( + ValueError, + match=re.escape(f"{follower.full_name} is locked to {target.full_name}"), + ): + follower.set(5) + + # the refused set changed nothing: neither the Target nor the own value + assert target.get() == 11 + assert follower.own_value() == 22 + + +def test_unlocked_lock_exposes_own_value(): + target, follower = make_target_and_follower() + lock_follower(follower, target, locked=False) + + # an unlocked Lock only remembers its Target + target.set(33) + assert follower.get() == 22 + + follower.set(44) + assert follower.get() == 44 + + +def test_locking_leaves_the_own_cache_untouched(): + target, follower = make_target_and_follower() + lock_follower(follower, target) + + assert follower.get() == 11 + # the locked get answers with the Target's value but must not write it + # into the Follower's own cache + assert follower.cache.get() == 22 + assert follower.own_value() == 22 + + # unlocking exposes the own value again (ADR-0002) + follower.lock.locked = False + assert follower.get() == 22 + + +def test_snapshot_without_lock_has_no_lock_entry(): + _, follower = make_target_and_follower() + + snap = follower.snapshot(update=False) + assert snap["value"] == 22 + assert "lock" not in snap + + +def test_snapshot_while_locked_reports_target_value_and_lock(): + target, follower = make_target_and_follower() + lock_follower(follower, target) + + snap = follower.snapshot(update=False) + assert snap["value"] == 11 + assert snap["lock"] == {"target": target.full_name, "locked": True} + + # the own value survives in the cache + assert follower.own_value() == 22 + + +def test_snapshot_while_unlocked_reports_own_value_and_lock(): + target, follower = make_target_and_follower() + lock_follower(follower, target, locked=False) + + snap = follower.snapshot(update=False) + assert snap["value"] == 22 + assert snap["lock"] == {"target": target.full_name, "locked": False} + + +def test_own_value_is_the_cached_own_value_regardless_of_state(): + target, follower = make_target_and_follower() + assert follower.own_value() == 22 + + lock_follower(follower, target) + assert follower.get() == 11 + assert follower.own_value() == 22 + + follower.lock.locked = False + assert follower.own_value() == 22 + + +def test_parameter_manager_creates_managed_parameters(): + pm = ParameterManager(name="pm_locks_unit") + pm.add_parameter("q01.x", initial_value=5, unit="V") + + assert isinstance(pm.parameter("q01.x"), ManagedParameter) + assert pm.get("q01.x") == 5 + + pm.set("q01.x", 6) + assert pm.get("q01.x") == 6 From 9c11376a6bc9909d1335796a5b9a97f201ee4889 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 11:24:37 -0500 Subject: [PATCH 023/107] 1.1: fix from review round 1: dotted full paths in locked-set error, single Target get in snapshot --- src/instrumentserver/params.py | 34 +++++++++++++++----- test/pytest/test_pm_locks.py | 59 +++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index fd855d0..9ca6497 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -73,13 +73,21 @@ class ManagedParameter(Parameter): remembers its Target. """ - def __init__(self, name: str, **kwargs: Any) -> None: + def __init__(self, name: str, path: str | None = None, **kwargs: Any) -> None: # The Lock state must exist before ``super().__init__``: creating the # parameter with an ``initial_value`` already runs ``set_raw``. self.lock: PMLockBluePrint | None = None self._target: ParameterBase | None = None + self._path: str | None = path super().__init__(name, **kwargs) + @property + def path(self) -> str: + """The parameter's full dotted path with the instrument name, the + form Locks and files use (``parameter_manager.q01.x``); for a + standalone parameter, its plain name.""" + return self._path if self._path is not None else self.name + @property def locked(self) -> bool: """Whether a Lock is present and currently locked.""" @@ -103,11 +111,10 @@ def get_raw(self) -> Any: def set_raw(self, value: Any) -> None: """Store the value while not locked; while locked, refuse with a - ``ValueError`` naming the Target.""" + ``ValueError`` naming the full dotted paths of Follower and Target.""" if self.locked: - raise ValueError( - f"{self.full_name} is locked to {self._locked_target().full_name}" - ) + assert self.lock is not None, "a locked Lock has no record" + raise ValueError(f"{self.path} is locked to {self.lock.target}") self.cache._set_from_raw_value(value) def _wrap_get(self, get_function: Callable[..., Any]) -> Callable[..., Any]: @@ -134,12 +141,19 @@ def snapshot_base( params_to_skip_update: Sequence[str] | None = None, ) -> Dict[str, Any]: """Snapshot with a ``lock`` entry while a Lock is present; while - locked, the reported ``value`` is the Target's value.""" + locked, the reported ``value`` is the Target's value. + + With ``update=True`` the base snapshot already asks this parameter, + whose locked get answers with the Target's value, so the Target is + not read a second time. With a falsy ``update`` the Target is read + directly — not its cache — so that each hop of a chain reads + according to its own state (D7). + """ snap = super().snapshot_base( update=update, params_to_skip_update=params_to_skip_update ) if self.lock is not None: - if self.locked: + if self.locked and update is not True: snap["value"] = self._locked_target().get() snap["lock"] = {"target": self.lock.target, "locked": self.lock.locked} return snap @@ -347,7 +361,10 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] Same dotted-name semantics as :meth:`ParameterGroup.add_parameter`, which this method calls; only the parameter class differs, so that - the Parameter Manager's parameters can carry a Lock. + the Parameter Manager's parameters can carry a Lock. The created + parameter's ``path`` is set to its full dotted path with the + instrument name (``parameter_manager.q01.x``), the form Locks and + files use. :param name: Name of the parameter; see :meth:`ParameterGroup.add_parameter`. @@ -357,6 +374,7 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] :return: None. """ kw["parameter_class"] = ManagedParameter + kw["path"] = f"{self.name}.{name}" super().add_parameter(name, **kw) @staticmethod diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 7b7bb02..95ff087 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -25,7 +25,19 @@ def lock_follower(follower, target, locked=True): """Attach a Lock to the Follower directly (no manager: task 1.2 adds the Lock API).""" follower._target = target - follower.lock = PMLockBluePrint(target=target.full_name, locked=locked) + follower.lock = PMLockBluePrint(target=target.path, locked=locked) + + +class CountingManagedParameter(ManagedParameter): + """ManagedParameter that counts how often its value is read.""" + + def __init__(self, name, **kwargs): + self.get_calls = 0 + super().__init__(name, **kwargs) + + def get_raw(self): + self.get_calls += 1 + return super().get_raw() def test_get_redirects_to_target_while_locked(): @@ -46,8 +58,7 @@ def test_set_while_locked_raises_and_names_the_target(): lock_follower(follower, target) with pytest.raises( - ValueError, - match=re.escape(f"{follower.full_name} is locked to {target.full_name}"), + ValueError, match=re.escape(f"{follower.path} is locked to {target.path}") ): follower.set(5) @@ -56,6 +67,26 @@ def test_set_while_locked_raises_and_names_the_target(): assert follower.own_value() == 22 +def test_set_while_locked_names_dotted_full_paths_inside_a_manager(): + pm = ParameterManager(name="parameter_manager") + pm.add_parameter("q01.x", initial_value=1, unit="V") + pm.add_parameter("q02.y", initial_value=2, unit="V") + + target = pm.parameter("q01.x") + follower = pm.parameter("q02.y") + assert target.path == "parameter_manager.q01.x" + assert follower.path == "parameter_manager.q02.y" + + follower._target = target + follower.lock = PMLockBluePrint(target="parameter_manager.q01.x", locked=True) + + with pytest.raises( + ValueError, + match=re.escape("parameter_manager.q02.y is locked to parameter_manager.q01.x"), + ): + follower.set(5) + + def test_unlocked_lock_exposes_own_value(): target, follower = make_target_and_follower() lock_follower(follower, target, locked=False) @@ -97,7 +128,7 @@ def test_snapshot_while_locked_reports_target_value_and_lock(): snap = follower.snapshot(update=False) assert snap["value"] == 11 - assert snap["lock"] == {"target": target.full_name, "locked": True} + assert snap["lock"] == {"target": target.path, "locked": True} # the own value survives in the cache assert follower.own_value() == 22 @@ -109,7 +140,25 @@ def test_snapshot_while_unlocked_reports_own_value_and_lock(): snap = follower.snapshot(update=False) assert snap["value"] == 22 - assert snap["lock"] == {"target": target.full_name, "locked": False} + assert snap["lock"] == {"target": target.path, "locked": False} + + +def test_locked_snapshot_gets_the_target_once_per_snapshot(): + target = CountingManagedParameter("target", set_cmd=None, initial_value=11) + follower = ManagedParameter("follower", set_cmd=None, initial_value=22) + lock_follower(follower, target) + + # update=True: the base snapshot asks this parameter, whose locked get + # already answers with the Target's value — no second Target get + snap = follower.snapshot(update=True) + assert snap["value"] == 11 + assert snap["lock"] == {"target": target.path, "locked": True} + assert target.get_calls == 1 + + # update=False: the Target is read directly, per its own state (D7) + snap = follower.snapshot(update=False) + assert snap["value"] == 11 + assert target.get_calls == 2 def test_own_value_is_the_cached_own_value_regardless_of_state(): From 229dc028e9e03e6b71d7e4bed41feb81c619528f Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 11:35:33 -0500 Subject: [PATCH 024/107] 1.1: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 32 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 5caf34b..11c768d 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -133,3 +133,35 @@ Two of the three pre-existing defects listed in D24 are fixed. In `src/instrumen - In round 0, test-reviewer-deepseek stalled on a provider "Upstream error" and plan-checker-deepseek's output turned into garbage. Neither left a report. After nudges, test-reviewer-deepseek wrote its report but stopped before worker_done, and plan-checker-deepseek hit two more provider errors, the last one on its report write. Both finished after further nudges. - Four permission requests were rejected: reviewer-deepseek asked for `/tmp` and then sent a garbled request; test-reviewer-deepseek sent a garbled request; and in re-review plan-checker-deepseek asked for a garbled path outside the repo. Each was pointed back to writing its report file. - In re-review, test-reviewer-deepseek's worker_done text came through garbled, but its report was at the right path. + +## 1.1 `ManagedParameter` — 2026-09-24 + +`src/instrumentserver/params.py` now has `ManagedParameter(Parameter)`, the first task of Phase 1. It carries `lock: PMLockBluePrint | None`, a private `_target` (the Target parameter object), a read-only `locked` property (true when a Lock is present and `lock.locked`), a `path` property (the full dotted path, or the plain name when standalone) and `own_value()`. While locked, `get` answers with the Target's value (pulled on each get, ADR-0002), `set` raises `ValueError(" is locked to ")`, and `snapshot_base` reports the Target's value; a `lock` entry is in the snapshot whenever a Lock exists, locked or not. `ParameterManager.add_parameter` creates `ManagedParameter`s and sets `path` to `f"{self.name}.{name}"`. `PMLockBluePrint(target, locked, _class_type="PMLockBluePrint")` is in `blueprints.py` and part of `BluePrintType`. The new `test/pytest/test_pm_locks.py` has 11 server-free tests; the Lock is wired by hand on the parameter objects, since the Lock API is task 1.2. + +### Commit by commit +- `0f58c83` The class, the blueprint, and 9 tests (get redirect with pull on Target change, set raises and changes nothing, unlocked Lock exposes the own value, cache untouched by locking, three snapshot cases, `own_value` in every state, the manager creating `ManagedParameter`s). Three things the task text did not spell out: + - Besides `get_raw`, the coder overrode `_wrap_get`. qcodes' get wrapper writes every answered value into the parameter's cache, so a locked get would have overwritten the Follower's own value, which unlocking must expose again. `test_locking_leaves_the_own_cache_untouched` checks this. + - `lock` and `_target` are set before `super().__init__`, because an `initial_value` already runs `set_raw`. + - `ParameterGroup.add_parameter` now uses `kw.setdefault("parameter_class", Parameter)` instead of forcing `Parameter`, so the manager's override reaches parameters created in submodules. + Orchestrator run: 22 passed in `test_pm_locks.py` + `test_param_manager.py`, 184 in the full suite. +- `9c11376` Fix from round 0, two items: + - The locked-set message used qcodes' `full_name`. Inside a manager that reads `q02_y is locked to q01_x`: underscore-joined, and without the instrument name because `ParameterGroup`s have no parent chain. reviewer-qwen caught the message and test-reviewer-qwen the test's blind spot (the test built its expected string from `full_name` too, so it could not tell). The orchestrator reproduced it and read the task's "full_name" as the plan's full dotted path (rule 4, D10, D19). The fix adds the `path` constructor argument and property, sets it in `ParameterManager.add_parameter`, and takes the Target half from `self.lock.target`. New test `test_set_while_locked_names_dotted_full_paths_inside_a_manager` checks `parameter_manager.q02.y is locked to parameter_manager.q01.x`. + - With `update=True`, `snapshot_base` read the Target twice: once through the base snapshot's `get`, again in the override. Raised as a nit by both general reviewers and plan-checker-glm, and sent because a fix round was happening anyway. The override now runs only when `update is not True`. For a falsy `update` it still calls the Target's `get()`, not its cache, so each hop of a chain reads by its own state (D7); reviewer-glm had suggested the cache, and the orchestrator told the coder not to. New test `test_locked_snapshot_gets_the_target_once_per_snapshot` counts Target reads with a `CountingManagedParameter` subclass. + All six reviewers approved in re-review, and each one that raised an item confirmed it fixed. Orchestrator run: 11 passed in `test_pm_locks.py`, 24 in the two named files, 186 in the full suite. + +### Dropped findings +- The locked redirect is written twice, in `get_raw` and in `_wrap_get` (reviewer-glm, nit) → not sent. The plan asks for `get_raw` by name, and both copies are one identical line. +- No test covers the `setdefault` passthrough in `ParameterGroup.add_parameter` (test-reviewer-glm, nit) → not sent. No caller passes `parameter_class`; noted for 1.2. +- No `PMLockBluePrint` serialization round-trip test (test-reviewer-qwen, nit) → not sent. The plan puts wire tests in 1.3. +- The `value` override ignores `snapshot_value=False`, and with `snapshot_get=False` a locked `snapshot(update=True)` would report the own value (reviewer-qwen round 0, plan-checker-qwen round 1, nits) → not sent. The Parameter Manager never creates such parameters. + +### Questions to Marcos +- Does the task text's `full_name` in the locked-set message mean the full dotted path (`parameter_manager.q02.y`) rather than qcodes' `full_name`? The orchestrator decided yes and flagged it for the run report. No answer is recorded yet. + +### Loose ends +- Carried forward in `decisions.md` for later tasks: v1 `toParamDict` saves the Target's value for a locked parameter until 4.1 (reviewer-qwen); `ParameterBroadcastBluePrint.value` is annotated `int | None` and needs widening in 1.3 (reviewer-qwen); a parameter added over the wire straight on a submodule group (`pm.q01.add_parameter`) is a plain `Parameter` with no `path`, which 1.2's validation must handle (test-reviewer-qwen). +- The error message now takes the Target half from the stored `lock.target`, not the live Target object, so 1.2 owns checking that the stored Target exists. + +### Process notes +- reviewer-qwen tried to write a scratch script to opencode's temp dir outside the repo. It was rejected, and the reviewer was told to use `orchestration/1.1/`; it ran and then deleted the script there, in both rounds. +- First run with the glm reviewers in place of deepseek: no stalls, no garbled output, and no nudges were needed. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 1452637..7f42a4a 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -443,7 +443,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 1 — Locks -- [ ] **1.1 `ManagedParameter`.** In `params.py`: `ManagedParameter(Parameter)` with +- [x] **1.1 `ManagedParameter`.** In `params.py`: `ManagedParameter(Parameter)` with attribute `lock: PMLockBluePrint | None` plus a private reference to the Target parameter object and a `locked` flag. `get_raw`: if locked → `return self._target.get()`; else own cached value. `set_raw`: if locked → `raise ValueError(f"{full_name} is locked to From be9005360cdff9d266498d2249db52860f2f0670 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 13:24:32 -0500 Subject: [PATCH 025/107] 1.2: Lock API on ParameterManager with validate-then-mutate checks and remove_parameter Lock cleanup --- src/instrumentserver/params.py | 245 +++++++++++++++++++- test/pytest/test_pm_locks.py | 396 ++++++++++++++++++++++++++++++++- 2 files changed, 633 insertions(+), 8 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 9ca6497..cd550a3 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -5,7 +5,7 @@ from enum import Enum, auto, unique from functools import wraps from pathlib import Path -from typing import Any, Callable, Dict, List, Union +from typing import Any, Callable, Dict, Iterator, List, Tuple, Union from qcodes import Parameter, validators from qcodes.instrument import InstrumentBase @@ -185,6 +185,17 @@ def _to_tree(cls, pm: "ParameterGroup") -> Dict: def to_tree(self) -> Dict: return ParameterGroup._to_tree(self) + def _iter_params(self) -> Iterator[Tuple[str, ParameterBase]]: + """Yield ``(relative dotted path, parameter)`` for every parameter + in this Parameter Group and its nested Parameter Groups, in tree + order. The paths are relative to this group.""" + for pname, param in self.parameters.items(): + yield pname, param + for smn, sm in self.submodules.items(): + assert isinstance(sm, ParameterGroup) + for path, param in sm._iter_params(): + yield f"{smn}.{path}", param + def _get_param(self, param_name: str) -> ParameterBase: parent = self._get_parent(param_name) try: @@ -316,7 +327,7 @@ class ParameterManager(Broadcaster, ParameterGroup): Allows extra-easy on-the-fly addition/removal of new parameters. The Parameter Manager is the root of the parameter tree. It extends the - Parameter Group with file, profile, and (later) Type and Lock logic; + Parameter Group with file, profile, Lock, and (later) Type logic; its submodules are plain Parameter Groups. It implements the Broadcaster contract, so the Server can register @@ -377,6 +388,236 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] kw["path"] = f"{self.name}.{name}" super().add_parameter(name, **kw) + def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: + """Remove a parameter, first removing every Lock whose Target it is + (ADR-0002): the Followers become plain parameters and answer ``get`` + with their own values again. + + Same signature and deletion behaviour as + :meth:`ParameterGroup.remove_parameter`; the path is relative to + this Parameter Manager. + """ + # validate-then-mutate: the parameter must exist before any Lock is + # touched. The checks mirror what the deletion itself would raise. + parent = self._get_parent(param_name) + pname = param_name.split(".")[-1] + if pname not in parent.parameters: + raise KeyError(pname) + + # every Lock pointing at the removed parameter goes away with it, + # locked or not: an unlocked Lock must not keep remembering a + # Target that no longer exists. + target_full = self._full_path(param_name) + for rel_path, param in self._iter_params(): + lock = getattr(param, "lock", None) + if lock is not None and lock.target == target_full: + assert isinstance(param, ManagedParameter) + param.lock = None + param._target = None + + super().remove_parameter(param_name, cleanup) + + # ------------------------------------------------------------------ + # Lock API (plan decision D9) + # + # A Lock lives on the Follower's :class:`ManagedParameter`: its + # :class:`PMLockBluePrint` records the Target as a full dotted path + # with the instrument name and whether the Lock is currently locked. + # Every method validates first and raises before touching anything + # (no partial state on error). Paths passed in by name and returned + # by name are dotted paths relative to this Parameter Manager; only + # ``PMLockBluePrint.target`` and the stored Lock Target use the full + # form, as :attr:`ManagedParameter.path` does. + + def _full_path(self, relative_name: str) -> str: + """The full dotted path (with the instrument name) of a path + relative to this Parameter Manager.""" + return f"{self.name}.{relative_name}" + + def _resolve_param(self, name: str) -> ParameterBase: + """The parameter object at a dotted path relative to this + Parameter Manager; raises ``ValueError`` naming the path when no + parameter exists there.""" + try: + parent = self._get_parent(name) + except ValueError: + raise ValueError(f"Parameter '{name}' does not exist") from None + pname = name.split(".")[-1] + if pname not in parent.parameters: + raise ValueError(f"Parameter '{name}' does not exist") + return parent.parameters[pname] + + def _param_by_full_path(self, full_path: str) -> ParameterBase | None: + """The parameter object at a full dotted path (the form a Lock's + Target is stored in), or ``None`` when the path points outside + this Parameter Manager or no parameter exists there.""" + prefix = f"{self.name}." + if not full_path.startswith(prefix): + return None + try: + return self._get_param(full_path[len(prefix):]) + except ValueError: + return None + + def _require_lock(self, param: ParameterBase, name: str) -> PMLockBluePrint: + """The Lock record of a Follower; raises ``ValueError`` naming the + path when the parameter carries no Lock.""" + lock = getattr(param, "lock", None) + if lock is None: + raise ValueError(f"{self._full_path(name)} has no Lock") + return lock + + def _check_lock_allowed(self, follower_full: str, target_full: str) -> None: + """Raise ``ValueError`` for a self-lock, or when locking would + close a cycle: the walk follows each Target's Lock regardless of + locked/unlocked state (D7).""" + if follower_full == target_full: + raise ValueError(f"cannot lock {follower_full} to itself") + chain = [target_full] + seen = {target_full} + current = target_full + while True: + param = self._param_by_full_path(current) + if param is None: + break + lock = getattr(param, "lock", None) + if lock is None: + break + nxt = lock.target + if nxt == follower_full or nxt in seen: + raise ValueError( + f"cannot lock {follower_full} to {target_full}: cycle in " + f"Lock targets: {' -> '.join(chain + [nxt])}" + ) + seen.add(nxt) + chain.append(nxt) + current = nxt + + def lock(self, name: str, target: str) -> None: + """Lock the parameter at ``name`` to the parameter at ``target`` + (dotted paths relative to this Parameter Manager). + + Creates the Lock, or re-targets it when one exists already, and + locks it: while locked, ``name`` answers ``get`` with the Target's + value and refuses ``set`` (ADR-0002). The Target must be a + parameter of this same Parameter Manager (D8). Raises + ``ValueError`` naming the paths when a path does not exist, the + Follower cannot carry a Lock, the Lock would be a self-lock, or it + would close a cycle (walking Targets regardless of locked/unlocked + state, D7). + + :param name: path of the Follower. + :param target: path of the Target. + """ + follower = self._resolve_param(name) + target_param = self._resolve_param(target) + follower_full = self._full_path(name) + target_full = self._full_path(target) + if not isinstance(follower, ManagedParameter): + raise ValueError(f"{follower_full} cannot carry a Lock") + self._check_lock_allowed(follower_full, target_full) + follower._target = target_param + follower.lock = PMLockBluePrint(target=target_full, locked=True) + + def unlock(self, name: str) -> None: + """Unlock the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager): it keeps remembering its + Target but answers ``get`` with its own value again (D5). Raises + ``ValueError`` naming the path when the parameter does not exist, + carries no Lock, or is not locked.""" + param = self._resolve_param(name) + lock = self._require_lock(param, name) + if not lock.locked: + raise ValueError(f"{self._full_path(name)} is not locked") + lock.locked = False + + def relock(self, name: str) -> None: + """Lock the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager) to its remembered Target again + (D5). Raises ``ValueError`` naming the paths when the parameter + does not exist, carries no Lock, is already locked, or when the + remembered Target is gone or locking to it would close a cycle + (D7).""" + param = self._resolve_param(name) + lock = self._require_lock(param, name) + follower_full = self._full_path(name) + if lock.locked: + raise ValueError(f"{follower_full} is already locked") + target_param = self._param_by_full_path(lock.target) + if target_param is None: + raise ValueError( + f"{follower_full} remembers Target {lock.target}, " + "which does not exist" + ) + self._check_lock_allowed(follower_full, lock.target) + assert isinstance(param, ManagedParameter) + param._target = target_param + lock.locked = True + + def toggle_lock(self, name: str) -> None: + """Toggle the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager): locked becomes unlocked and + unlocked becomes locked again (D5). Raises ``ValueError`` naming + the path when the parameter does not exist or carries no Lock, and + like :meth:`relock` when locking back would close a cycle.""" + param = self._resolve_param(name) + lock = self._require_lock(param, name) + if lock.locked: + self.unlock(name) + else: + self.relock(name) + + def remove_lock(self, name: str) -> None: + """Remove the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager) entirely: the Target is + forgotten and the parameter behaves as a plain parameter again + (D5). Raises ``ValueError`` naming the path when the parameter + does not exist or carries no Lock.""" + param = self._resolve_param(name) + self._require_lock(param, name) + assert isinstance(param, ManagedParameter) + param.lock = None + param._target = None + + def get_lock(self, name: str) -> "PMLockBluePrint | None": + """The Lock of the parameter at ``name`` (dotted path relative to + this Parameter Manager) as a :class:`PMLockBluePrint` whose Target + is the full dotted path, or ``None`` when it carries no Lock. + Raises ``ValueError`` naming the path when the parameter does not + exist.""" + param = self._resolve_param(name) + lock = getattr(param, "lock", None) + if lock is None: + return None + return PMLockBluePrint(target=lock.target, locked=lock.locked) + + def list_locks(self) -> "Dict[str, PMLockBluePrint]": + """All Locks in this Parameter Manager, locked and unlocked alike + (D5), as a mapping from the Follower's path (relative to this + Parameter Manager) to its :class:`PMLockBluePrint`.""" + locks: Dict[str, PMLockBluePrint] = {} + for rel_path, param in self._iter_params(): + lock = getattr(param, "lock", None) + if lock is not None: + locks[rel_path] = PMLockBluePrint( + target=lock.target, locked=lock.locked + ) + return locks + + def followers_of(self, name: str) -> "List[str]": + """Paths (relative to this Parameter Manager) of every Follower + whose Lock points at the parameter at ``name``, locked and + unlocked alike. Raises ``ValueError`` naming the path when the + parameter does not exist.""" + self._resolve_param(name) + target_full = self._full_path(name) + followers: List[str] = [] + for rel_path, param in self._iter_params(): + lock = getattr(param, "lock", None) + if lock is not None and lock.target == target_full: + followers.append(rel_path) + return followers + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 95ff087..8f04548 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -1,9 +1,12 @@ -"""Unit tests for ``ManagedParameter`` (plan task 1.1). - -Two standalone ManagedParameters (a Target and a Follower) with no -Parameter Manager involved. The Lock is wired directly on the parameter -objects: the Lock API on the Parameter Manager is task 1.2, its Broadcasts -task 1.3. +"""Unit tests for ``ManagedParameter`` and the Lock API (plan tasks 1.1 +and 1.2). + +The first part wires two standalone ManagedParameters (a Target and a +Follower) by hand, with no Parameter Manager involved. The second part +exercises the Lock API on a local Parameter Manager (``lock``, ``unlock``, +``relock``, ``toggle_lock``, ``remove_lock``, ``get_lock``, ``list_locks``, +``followers_of``, and the ``remove_parameter`` Lock cleanup). Its +Broadcasts are task 1.3. """ import re @@ -182,3 +185,384 @@ def test_parameter_manager_creates_managed_parameters(): pm.set("q01.x", 6) assert pm.get("q01.x") == 6 + + +# --------------------------------------------------------------------------- +# Lock API on the Parameter Manager (plan task 1.2, D9) +# --------------------------------------------------------------------------- + +@pytest.fixture +def pm(tmp_path, monkeypatch): + """A fresh Parameter Manager in an empty working directory, with a few + parameters to lock.""" + monkeypatch.chdir(tmp_path) + manager = ParameterManager(name="parameter_manager") + manager.add_parameter("q01.x", initial_value=1, unit="V") + manager.add_parameter("q01.y", initial_value=2, unit="V") + manager.add_parameter("q02.x", initial_value=3, unit="V") + manager.add_parameter("q02.y", initial_value=4, unit="V") + manager.add_parameter("q01Data.IF", initial_value=10, unit="Hz") + return manager + + +def test_lock_creates_a_locked_lock_and_get_pulls(pm): + pm.lock("q01.x", "q01Data.IF") + + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.parameter("q01.x").locked + assert pm.get("q01.x") == 10 + + # pull on get: changing the Target is enough, nothing is pushed + pm.set("q01Data.IF", 20) + assert pm.get("q01.x") == 20 + + # set on the locked Follower raises, naming the full dotted paths + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.x is locked to parameter_manager.q01Data.IF" + ), + ): + pm.set("q01.x", 99) + + +def test_names_go_in_relative_and_blueprint_targets_come_out_full(pm): + pm.lock("q02.x", "q01Data.IF") + + lock = pm.get_lock("q02.x") + assert lock is not None + assert lock.target == "parameter_manager.q01Data.IF" + assert list(pm.list_locks()) == ["q02.x"] + assert pm.followers_of("q01Data.IF") == ["q02.x"] + + +def test_lock_with_unknown_follower_or_target_raises_naming_the_path(pm): + with pytest.raises(ValueError, match="Parameter 'nope.x' does not exist"): + pm.lock("nope.x", "q01Data.IF") + + with pytest.raises(ValueError, match="Parameter 'nope.IF' does not exist"): + pm.lock("q01.x", "nope.IF") + + # the failed calls left no Lock behind + assert pm.list_locks() == {} + + +def test_self_lock_raises_naming_the_path(pm): + with pytest.raises( + ValueError, match="cannot lock parameter_manager.q01.x to itself" + ): + pm.lock("q01.x", "q01.x") + + assert pm.get_lock("q01.x") is None + + +def test_lock_refuses_a_two_node_cycle_and_names_every_path(pm): + pm.lock("q01.x", "q01.y") + + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.y to parameter_manager.q01.x: " + "cycle in Lock targets: " + "parameter_manager.q01.x -> parameter_manager.q01.y" + ), + ): + pm.lock("q01.y", "q01.x") + + # the refused lock changed nothing + assert pm.get_lock("q01.y") is None + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01.y", locked=True + ) + + +def test_lock_walks_targets_regardless_of_locked_state(pm): + # q01.x carries an unlocked Lock that remembers q01Data.IF; the cycle + # walk must still see it (D7) + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + + with pytest.raises(ValueError, match="cycle in Lock targets"): + pm.lock("q01Data.IF", "q01.x") + + # the Target chain a -> b -> c closes on a: locking a to c is refused, + # and the message walks the cycle from the proposed Target + pm.lock("q01.y", "q01.x") + pm.lock("q02.x", "q01.y") + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.x to parameter_manager.q02.x: " + "cycle in Lock targets: parameter_manager.q02.x -> " + "parameter_manager.q01.y -> parameter_manager.q01.x" + ), + ): + pm.lock("q01.x", "q02.x") + + +def test_lock_re_targets_an_existing_lock(pm): + pm.lock("q01.y", "q01Data.IF") + pm.lock("q01.y", "q02.x") + + assert pm.get_lock("q01.y") == PMLockBluePrint( + target="parameter_manager.q02.x", locked=True + ) + assert pm.followers_of("q01Data.IF") == [] + assert pm.followers_of("q02.x") == ["q01.y"] + assert pm.get("q01.y") == 3 + + +def test_a_failed_re_target_leaves_the_old_lock_untouched(pm): + pm.lock("q02.x", "q01Data.IF") + pm.lock("q01.y", "q02.x") # q01.y follows q02.x + + # re-targeting q02.x to q01.y would close the cycle q02.x -> q01.y -> q02.x + with pytest.raises(ValueError, match="cycle in Lock targets"): + pm.lock("q02.x", "q01.y") + + assert pm.get_lock("q02.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q02.x") == 10 + + +def test_unlock_keeps_the_target_and_exposes_the_own_value(pm): + pm.lock("q01.x", "q01Data.IF") + + pm.unlock("q01.x") + + # unlocked Lock remembers its Target (D5) + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + assert pm.get("q01.x") == 1 + pm.set("q01.x", 7) + assert pm.get("q01.x") == 7 + # an unlocked Follower still counts as a Follower + assert pm.followers_of("q01Data.IF") == ["q01.x"] + + +def test_relock_locks_to_the_remembered_target(pm): + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + + pm.relock("q01.x") + + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q01.x") == 10 + # the own value is still there for the next unlock + pm.unlock("q01.x") + assert pm.get("q01.x") == 1 + + +def test_relock_refuses_a_cycle_and_stays_unlocked(pm): + # The Lock API itself cannot build this state: locking q01Data.IF to + # q01.x is refused while q01.x still remembers it, even unlocked. It is + # reachable by hand-wiring (or through the GUI's remembered target, as + # the design mock's toggleLock), so relock re-validates: locking back + # to a remembered Target that now follows the Follower is refused. + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + q01_data_if = pm.parameter("q01Data.IF") + q01_data_if._target = pm.parameter("q01.x") + q01_data_if.lock = PMLockBluePrint( + target="parameter_manager.q01.x", locked=True + ) + + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.x to parameter_manager.q01Data.IF: " + "cycle in Lock targets: " + "parameter_manager.q01Data.IF -> parameter_manager.q01.x" + ), + ): + pm.relock("q01.x") + + # the refused relock left the Lock unlocked + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + + +def test_toggle_lock_switches_both_ways(pm): + pm.lock("q01.x", "q01Data.IF") + + pm.toggle_lock("q01.x") + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + assert pm.get("q01.x") == 1 + + pm.toggle_lock("q01.x") + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q01.x") == 10 + + +def test_state_inconsistent_calls_raise_naming_the_path(pm): + # no Lock at all + for call in (pm.unlock, pm.relock, pm.toggle_lock, pm.remove_lock): + with pytest.raises( + ValueError, match="parameter_manager.q01.x has no Lock" + ): + call("q01.x") + + # Lock present but in the other state already + pm.lock("q01.x", "q01Data.IF") + with pytest.raises( + ValueError, match="parameter_manager.q01.x is already locked" + ): + pm.relock("q01.x") + pm.unlock("q01.x") + with pytest.raises( + ValueError, match="parameter_manager.q01.x is not locked" + ): + pm.unlock("q01.x") + + +def test_remove_lock_forgets_the_target_entirely(pm): + pm.lock("q01.x", "q01Data.IF") + + pm.remove_lock("q01.x") + + assert pm.get_lock("q01.x") is None + assert pm.list_locks() == {} + assert pm.followers_of("q01Data.IF") == [] + assert pm.get("q01.x") == 1 + pm.set("q01.x", 42) + assert pm.get("q01.x") == 42 + + +def test_get_lock_and_list_locks_without_locks(pm): + assert pm.get_lock("q01.x") is None + assert pm.list_locks() == {} + + +def test_followers_of_lists_locked_and_unlocked_followers(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01Data.IF") + pm.unlock("q02.x") + + assert pm.followers_of("q01Data.IF") == ["q01.x", "q02.x"] + # a parameter nobody follows + assert pm.followers_of("q02.y") == [] + + +def test_chain_reads_hop_by_hop_per_own_state(pm): + pm.lock("q01.y", "q01Data.IF") # middle hop + pm.lock("q02.x", "q01.y") # end of the chain + + # both locked: q02.x pulls through q01.y to q01Data.IF + assert pm.get("q02.x") == 10 + + # q01.y unlocked: q02.x reads q01.y's own value (D7) + pm.unlock("q01.y") + pm.set("q01.y", 5) + assert pm.get("q02.x") == 5 + assert pm.followers_of("q01.y") == ["q02.x"] + + # q01.y locked again: the chain pulls through once more + pm.relock("q01.y") + assert pm.get("q02.x") == 10 + + +def test_remove_parameter_removes_every_lock_pointing_at_it(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01Data.IF") + pm.unlock("q02.x") + + pm.remove_parameter("q01Data.IF") + + # both Followers are plain parameters again (D3), whatever the state was + assert pm.get_lock("q01.x") is None + assert pm.get_lock("q02.x") is None + assert pm.list_locks() == {} + assert pm.get("q01.x") == 1 + assert pm.get("q02.x") == 3 + pm.set("q01.x", 11) + assert pm.get("q01.x") == 11 + + +def test_remove_parameter_leaves_unrelated_locks_alone(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q02.y") + + pm.remove_parameter("q02.y") # Target of q02.x only + + assert pm.get_lock("q02.x") is None + # q01.x still follows q01Data.IF + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q01.x") == 10 + + +def test_remove_parameter_of_a_chain_middle(pm): + pm.lock("q01.y", "q01Data.IF") # Follower of q01Data.IF ... + pm.lock("q02.x", "q01.y") # ... and Target of q02.x + + pm.remove_parameter("q01.y") + + # q02.x followed q01.y, which is gone: plain parameter again + assert pm.get_lock("q02.x") is None + assert pm.get("q02.x") == 3 + # q01Data.IF is untouched and now has no Followers + assert pm.get("q01Data.IF") == 10 + assert pm.followers_of("q01Data.IF") == [] + + +def test_remove_all_parameters_clears_every_lock(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01.y") + + pm.remove_all_parameters() + + assert pm.list() == [] + assert pm.list_locks() == {} + + +def test_lock_with_a_plain_group_parameter(pm): + # a parameter added directly on a Parameter Group (as the wire call + # pm.q01.add_parameter does) is a plain qcodes Parameter + pm.q01.add_parameter("plain", initial_value=7) + + # it cannot carry a Lock + with pytest.raises( + ValueError, match="parameter_manager.q01.plain cannot carry a Lock" + ): + pm.lock("q01.plain", "q01.x") + assert pm.get_lock("q01.plain") is None + + # but the Target knows nothing (D3): following it works + pm.lock("q01.x", "q01.plain") + assert pm.get("q01.x") == 7 + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01.plain", locked=True + ) + assert pm.followers_of("q01.plain") == ["q01.x"] + # a plain Parameter never shows up as a Follower + assert "q01.plain" not in pm.list_locks() + + +def test_target_paths_must_be_relative_to_the_manager(pm): + # rule 4: paths are relative to the Parameter Manager. A full-form + # path and a foreign root both name a parameter this manager cannot + # resolve (D8: Targets only inside the same Parameter Manager). + with pytest.raises( + ValueError, + match=re.escape("Parameter 'parameter_manager.q01.y' does not exist"), + ): + pm.lock("q01.x", "parameter_manager.q01.y") + + with pytest.raises( + ValueError, match=re.escape("Parameter 'other.z' does not exist") + ): + pm.lock("q01.x", "other.z") + + assert pm.list_locks() == {} From c636f5c3493bd9d7aa20f4c368d9bb2cbddbbfa3 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 13:30:24 -0500 Subject: [PATCH 026/107] 1.2: fix from review round 1: unlock/relock on an already-set Lock are logged INFO no-ops instead of raising --- src/instrumentserver/params.py | 19 +++++++----- test/pytest/test_pm_locks.py | 57 ++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index cd550a3..a74e4c8 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -523,26 +523,31 @@ def unlock(self, name: str) -> None: """Unlock the Lock of the parameter at ``name`` (dotted path relative to this Parameter Manager): it keeps remembering its Target but answers ``get`` with its own value again (D5). Raises - ``ValueError`` naming the path when the parameter does not exist, - carries no Lock, or is not locked.""" + ``ValueError`` naming the path when the parameter does not exist + or carries no Lock; unlocking an already unlocked Lock does + nothing and logs at INFO level.""" param = self._resolve_param(name) lock = self._require_lock(param, name) if not lock.locked: - raise ValueError(f"{self._full_path(name)} is not locked") + logger.info( + f"{self._full_path(name)} is already unlocked; nothing to do" + ) + return lock.locked = False def relock(self, name: str) -> None: """Lock the Lock of the parameter at ``name`` (dotted path relative to this Parameter Manager) to its remembered Target again (D5). Raises ``ValueError`` naming the paths when the parameter - does not exist, carries no Lock, is already locked, or when the - remembered Target is gone or locking to it would close a cycle - (D7).""" + does not exist, carries no Lock, or when the remembered Target is + gone or locking to it would close a cycle (D7); relocking an + already locked Lock does nothing and logs at INFO level.""" param = self._resolve_param(name) lock = self._require_lock(param, name) follower_full = self._full_path(name) if lock.locked: - raise ValueError(f"{follower_full} is already locked") + logger.info(f"{follower_full} is already locked; nothing to do") + return target_param = self._param_by_full_path(lock.target) if target_param is None: raise ValueError( diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 8f04548..1858f87 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -9,6 +9,7 @@ Broadcasts are task 1.3. """ +import logging import re import pytest @@ -405,26 +406,62 @@ def test_toggle_lock_switches_both_ways(pm): assert pm.get("q01.x") == 10 -def test_state_inconsistent_calls_raise_naming_the_path(pm): - # no Lock at all +def test_calls_without_a_lock_raise_naming_the_path(pm): for call in (pm.unlock, pm.relock, pm.toggle_lock, pm.remove_lock): with pytest.raises( ValueError, match="parameter_manager.q01.x has no Lock" ): call("q01.x") - # Lock present but in the other state already + +def test_unlock_on_an_already_unlocked_lock_is_a_logged_no_op(pm, caplog): pm.lock("q01.x", "q01Data.IF") - with pytest.raises( - ValueError, match="parameter_manager.q01.x is already locked" - ): - pm.relock("q01.x") pm.unlock("q01.x") - with pytest.raises( - ValueError, match="parameter_manager.q01.x is not locked" - ): + target_obj = pm.parameter("q01Data.IF") + follower = pm.parameter("q01.x") + + with caplog.at_level(logging.INFO): pm.unlock("q01.x") + # state unchanged: the Lock is still present and unlocked, still + # remembering its Target, and the own value still answers get() + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + assert follower._target is target_obj + assert pm.get("q01.x") == 1 + records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(records) == 1 + assert ( + "parameter_manager.q01.x is already unlocked; nothing to do" + in records[0].getMessage() + ) + + +def test_relock_on_an_already_locked_lock_is_a_logged_no_op(pm, caplog): + pm.lock("q01.x", "q01Data.IF") + follower = pm.parameter("q01.x") + # sentinel: relock's mutation path would re-resolve the remembered + # Target into _target and replace this reference; the no-op must not + sentinel = pm.parameter("q02.y") + follower._target = sentinel + value_before = pm.get("q01.x") + + with caplog.at_level(logging.INFO): + pm.relock("q01.x") + + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert follower._target is sentinel + assert pm.get("q01.x") == value_before + records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(records) == 1 + assert ( + "parameter_manager.q01.x is already locked; nothing to do" + in records[0].getMessage() + ) + def test_remove_lock_forgets_the_target_entirely(pm): pm.lock("q01.x", "q01Data.IF") From 8f7fb110bc20e40f2e71babae0e7d8006a2424f3 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 13:47:00 -0500 Subject: [PATCH 027/107] 1.2: fix from review round 2: name every missing path in lock, unknown-path tests for all Lock methods, relock missing-Target test --- src/instrumentserver/params.py | 17 +++++++-- test/pytest/test_pm_locks.py | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index a74e4c8..9410724 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -501,7 +501,8 @@ def lock(self, name: str, target: str) -> None: locks it: while locked, ``name`` answers ``get`` with the Target's value and refuses ``set`` (ADR-0002). The Target must be a parameter of this same Parameter Manager (D8). Raises - ``ValueError`` naming the paths when a path does not exist, the + ``ValueError`` naming every offending path when a path does not + exist (both paths are checked before one error is raised), the Follower cannot carry a Lock, the Lock would be a self-lock, or it would close a cycle (walking Targets regardless of locked/unlocked state, D7). @@ -509,8 +510,18 @@ def lock(self, name: str, target: str) -> None: :param name: path of the Follower. :param target: path of the Target. """ - follower = self._resolve_param(name) - target_param = self._resolve_param(target) + # validate-then-mutate: resolve both paths up front and name every + # missing one in a single error (rule 3) + resolved: List[ParameterBase] = [] + missing: List[str] = [] + for path in (name, target): + try: + resolved.append(self._resolve_param(path)) + except ValueError as exc: + missing.append(str(exc)) + if missing: + raise ValueError("; ".join(missing)) + follower, target_param = resolved follower_full = self._full_path(name) target_full = self._full_path(target) if not isinstance(follower, ManagedParameter): diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 1858f87..3c31081 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -250,6 +250,73 @@ def test_lock_with_unknown_follower_or_target_raises_naming_the_path(pm): assert pm.list_locks() == {} +def test_lock_with_two_unknown_paths_names_both(pm): + with pytest.raises(ValueError) as excinfo: + pm.lock("nope1", "nope2") + + # every offending path, not the first (rule 3) + assert str(excinfo.value) == ( + "Parameter 'nope1' does not exist; Parameter 'nope2' does not exist" + ) + assert pm.list_locks() == {} + + +@pytest.mark.parametrize( + "call", + [ + lambda pm: pm.unlock("nope.x"), + lambda pm: pm.relock("nope.x"), + lambda pm: pm.toggle_lock("nope.x"), + lambda pm: pm.remove_lock("nope.x"), + lambda pm: pm.get_lock("nope.x"), + lambda pm: pm.followers_of("nope.x"), + ], + ids=[ + "unlock", + "relock", + "toggle_lock", + "remove_lock", + "get_lock", + "followers_of", + ], +) +def test_unknown_path_raises_naming_the_path_and_changes_nothing(pm, call): + # a Lock exists, so list_locks proves the failed call changed nothing + pm.lock("q01.x", "q01Data.IF") + + with pytest.raises(ValueError, match="Parameter 'nope.x' does not exist"): + call(pm) + + assert pm.list_locks() == { + "q01.x": PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + } + + +def test_relock_with_a_missing_remembered_target_raises_naming_both(pm): + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + # hand-wire a remembered Target that no longer exists + pm.parameter("q01.x").lock = PMLockBluePrint( + target="parameter_manager.gone", locked=False + ) + + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.x remembers Target parameter_manager.gone, " + "which does not exist" + ), + ): + pm.relock("q01.x") + + # the refused relock left the Lock unlocked + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.gone", locked=False + ) + + def test_self_lock_raises_naming_the_path(pm): with pytest.raises( ValueError, match="cannot lock parameter_manager.q01.x to itself" From 3481f562f87b0ee0b2341d9816b081203ad736cf Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 13:54:51 -0500 Subject: [PATCH 028/107] 1.2: fix from review round 3: Parameter Groups route add/remove to the root Parameter Manager --- src/instrumentserver/params.py | 70 +++++++++++++++++++++++++++++++--- test/pytest/test_pm_locks.py | 58 ++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 9410724..7d3bd4b 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -169,9 +169,24 @@ class ParameterGroup(InstrumentBase): has no file, profile, Type or Lock logic of its own. Every submodule of a Parameter Manager is a Parameter Group; only the root is the Parameter Manager, which extends the Parameter Group with those - responsibilities. + responsibilities. When a Parameter Group belongs to a Parameter + Manager, its public ``add_parameter``/``remove_parameter`` route to + the root, so ``ManagedParameter`` creation and the Lock cleanup always + happen; a standalone Parameter Group behaves as a plain container. """ + #: The root Parameter Manager this Parameter Group belongs to, or + #: ``None`` for a standalone Parameter Group or the root itself. When + #: set, the public add/remove methods route to the root (D15): only + #: the root owns ``ManagedParameter`` creation and Lock logic. + _root: "ParameterManager | None" = None + + #: This Parameter Group's dotted path relative to its root Parameter + #: Manager, with a trailing dot (``"q01.ro."``); empty on the root and + #: on standalone Parameter Groups. Set together with ``_root`` at + #: creation and used to build root-relative paths when routing. + _path_prefix: str = "" + @classmethod def _to_tree(cls, pm: "ParameterGroup") -> Dict: ret: dict[str, Any] = {} @@ -211,21 +226,32 @@ def _get_parent( split_names = param_name.split(".") parent = self full_name = self.name + prefix = self._path_prefix for i, n in enumerate(split_names[:-1]): full_name += f".{n}" + prefix += f"{n}." if n in parent.parameters: raise ValueError( f"{n} is a parameter, and cannot have child parameters." ) if n not in parent.submodules: if create_parent: - parent.add_submodule(n, ParameterGroup(n)) # type: ignore[type-var] + new_group = ParameterGroup(n) + new_group._root = self._root_for_new_groups() + new_group._path_prefix = prefix + parent.add_submodule(n, new_group) # type: ignore[type-var] else: raise ValueError(f"{n} does not exist.") parent = parent.submodules[n] # type: ignore[assignment] return parent + def _root_for_new_groups(self) -> "ParameterManager | None": + """The root Parameter Manager that nested Parameter Groups created + under this group belong to: this group's root, or ``None`` when + this group is standalone.""" + return self._root + def has_param(self, param_name: str) -> bool: try: self._get_param(param_name) @@ -236,6 +262,12 @@ def has_param(self, param_name: str) -> bool: def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] """Add a parameter. + A Parameter Group that belongs to a Parameter Manager routes the + call to the root, with the path made relative to it + (``.``), so the parameter is created as a + :class:`ManagedParameter` that can carry a Lock (D15). A + standalone Parameter Group creates a plain qcodes ``Parameter``. + :param name: Name of the parameter. If the name contains `.`s, then an element before a dot is interpreted as a submodule. Multiple dots represent nested submodules. I.e., when @@ -249,18 +281,39 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] - ``vals`` defaults to ``qcodes.utils.validators.Anything()``. :return: None. """ + if self._root is not None: + self._root.add_parameter(f"{self._path_prefix}{name}", **kw) + return kw.setdefault("parameter_class", Parameter) if "vals" not in kw: kw["vals"] = validators.Anything() kw["set_cmd"] = None parent = self._get_parent(name, create_parent=True) - if parent is self: - super().add_parameter(name.split(".")[-1], **kw) - else: - parent.add_parameter(name.split(".")[-1], **kw) + parent._add_own_parameter(name.split(".")[-1], **kw) + + def _add_own_parameter(self, name: str, **kw: Any) -> None: + """Create a parameter directly on this Parameter Group, without + routing: the plain creation path the root's methods end in.""" + super().add_parameter(name, **kw) def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: + """Remove a parameter. + + A Parameter Group that belongs to a Parameter Manager routes the + call to the root, with the path made relative to it, so the + root's Lock cleanup happens before the deletion (D3, D15). A + standalone Parameter Group deletes directly. + + :param param_name: Name of the parameter; dotted names traverse + nested Parameter Groups. + :param cleanup: Whether to remove emptied submodules afterwards. + """ + if self._root is not None: + self._root.remove_parameter( + f"{self._path_prefix}{param_name}", cleanup=cleanup + ) + return parent = self._get_parent(param_name) pname = param_name.split(".")[-1] del parent.parameters[pname] @@ -388,6 +441,11 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] kw["path"] = f"{self.name}.{name}" super().add_parameter(name, **kw) + def _root_for_new_groups(self) -> "ParameterManager": + """Parameter Groups created under the Parameter Manager belong to + it: it is their root.""" + return self + def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: """Remove a parameter, first removing every Lock whose Target it is (ADR-0002): the Followers become plain parameters and answer ``get`` diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 3c31081..44fe07e 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -15,7 +15,11 @@ import pytest from instrumentserver.blueprints import PMLockBluePrint -from instrumentserver.params import ManagedParameter, ParameterManager +from instrumentserver.params import ( + ManagedParameter, + ParameterGroup, + ParameterManager, +) def make_target_and_follower(): @@ -632,9 +636,10 @@ def test_remove_all_parameters_clears_every_lock(pm): def test_lock_with_a_plain_group_parameter(pm): - # a parameter added directly on a Parameter Group (as the wire call - # pm.q01.add_parameter does) is a plain qcodes Parameter - pm.q01.add_parameter("plain", initial_value=7) + # a plain qcodes Parameter (no lock, no path) can only end up inside a + # Parameter Manager through direct, unrouted creation — legacy or + # foreign code. The Lock API must still handle it. + pm.q01._add_own_parameter("plain", set_cmd=None, initial_value=7) # it cannot carry a Lock with pytest.raises( @@ -654,6 +659,51 @@ def test_lock_with_a_plain_group_parameter(pm): assert "q01.plain" not in pm.list_locks() +def test_group_remove_parameter_delegates_to_the_root(pm): + pm.lock("q01.x", "q02.y") + + pm.q02.remove_parameter("y") + + # the Lock pointing at the removed Target went with it (D3), through + # the root's cleanup + assert pm.list_locks() == {} + assert pm.get_lock("q01.x") is None + assert pm.get("q01.x") == 1 + pm.set("q01.x", 5) + assert pm.get("q01.x") == 5 + + +def test_group_add_parameter_creates_a_managed_parameter(pm): + pm.q01.add_parameter("z", initial_value=1) + + param = pm.parameter("q01.z") + assert isinstance(param, ManagedParameter) + assert param.path == "parameter_manager.q01.z" + # it can carry a Lock + pm.lock("q01.z", "q01Data.IF") + assert pm.get("q01.z") == 10 + + +def test_nested_group_add_parameter_creates_a_managed_parameter(pm): + pm.add_parameter("q01.ro.IF", initial_value=2) # creates the depth-2 group + + pm.q01.ro.add_parameter("gain", initial_value=3) + + param = pm.parameter("q01.ro.gain") + assert isinstance(param, ManagedParameter) + assert param.path == "parameter_manager.q01.ro.gain" + pm.lock("q01.ro.gain", "q01.ro.IF") + assert pm.get("q01.ro.gain") == 2 + + +def test_standalone_group_keeps_plain_parameters(): + solo = ParameterGroup("solo_group") + solo.add_parameter("p", initial_value=1) + + assert solo._root is None + assert not isinstance(solo.parameter("p"), ManagedParameter) + + def test_target_paths_must_be_relative_to_the_manager(pm): # rule 4: paths are relative to the Parameter Manager. A full-form # path and a foreign root both name a parameter this manager cannot From d1937c886226beb2617e7ceeabd57ed3bc2b022b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 14:05:51 -0500 Subject: [PATCH 029/107] 1.2: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 36 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 11c768d..559f302 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -165,3 +165,39 @@ Two of the three pre-existing defects listed in D24 are fixed. In `src/instrumen ### Process notes - reviewer-qwen tried to write a scratch script to opencode's temp dir outside the repo. It was rejected, and the reviewer was told to use `orchestration/1.1/`; it ran and then deleted the script there, in both rounds. - First run with the glm reviewers in place of deepseek: no stalls, no garbled output, and no nudges were needed. + +## 1.2 Lock API on `ParameterManager` — 2026-09-24 + +`ParameterManager` in `src/instrumentserver/params.py` now has the D9 Lock API: `lock`, `unlock`, `relock`, `toggle_lock`, `remove_lock`, `get_lock`, `list_locks` and `followers_of`. Paths go in and come out relative to the Parameter Manager; the Target stored in `PMLockBluePrint` is the full dotted path. Every method validates before it changes anything: `_resolve_param` names an unknown path, `_require_lock` names a parameter with no Lock, and `_check_lock_allowed` refuses a self-lock or a cycle, walking Targets whether their Locks are locked or not (D7) and naming the whole chain. `remove_parameter` is overridden to remove every Lock whose Target is the removed parameter, locked or unlocked, before deleting (D3); `remove_all_parameters` and `fromParamDict` go through it. By the end of the task, Parameter Groups inside a Parameter Manager also route their `add_parameter`/`remove_parameter` to the root. `test/pytest/test_pm_locks.py` grew from 11 to 48 server-free tests. + +### Commit by commit +- `be90053` The Lock API, the `remove_parameter` override, `ParameterGroup._iter_params` (yields every parameter in the tree with its relative path), and 23 tests on a `pm` fixture run under `monkeypatch.chdir(tmp_path)`. They cover the redirect and pull, re-targeting (`test_lock_re_targets_an_existing_lock`, `test_a_failed_re_target_leaves_the_old_lock_untouched`), two- and three-node cycles including one through an unlocked Lock, relock re-running the cycle check on hand-wired state, chains read hop by hop, and Lock cleanup on removal, including a chain middle. Before writing the code the coder asked three questions. The orchestrator answered two from the plan: calls on a parameter with no Lock raise `ValueError` naming the path, and a plain qcodes `Parameter` inside the tree can be a Target but not a Follower (`"... cannot carry a Lock"`, pinned by `test_lock_with_a_plain_group_parameter`). The third went to Marcos (see Questions). The coder's `ask` timed out after about 40 minutes, before the answer arrived, so it went ahead with strict raising for that case. Orchestrator run: 47 passed in the two named files, 209 in the full suite. +- `c636f5c` Fix round 1, sent before any review because the behaviour went against Marcos's answer, so reviewing it made no sense. `unlock` on an unlocked Lock and `relock` on a locked Lock now return after one `logger.info` line (` is already unlocked; nothing to do` / `... is already locked; nothing to do`). The no-op `relock` does not re-run the Target or cycle checks and does not touch `_target`. `test_state_inconsistent_calls_raise_naming_the_path` became `test_calls_without_a_lock_raise_naming_the_path` plus two `caplog` tests; the `relock` one plants a sentinel on `_target` to show the mutation path never ran. The round-0 reviewers reviewed this commit together with `be90053`. Orchestrator run: 49 in the named files, 211 in the full suite. +- `8f7fb11` Fix round 2, from round 0: + - `lock` now resolves both paths first and joins every failure into one `ValueError`, so `lock("nope1", "nope2")` names both. Before, it stopped at the first, against plan rule 3. Caught by plan-checker-glm and reviewer-glm (both should-fix). New test `test_lock_with_two_unknown_paths_names_both`. + - `test_unknown_path_raises_naming_the_path_and_changes_nothing`, run for `unlock`, `relock`, `toggle_lock`, `remove_lock`, `get_lock` and `followers_of`. Only `lock` had an unknown-path test. Caught by both test reviewers (test-reviewer-glm must-fix, test-reviewer-qwen should-fix), citing the plan's rule that every error path has a test. + - `test_relock_with_a_missing_remembered_target_raises_naming_both` hand-wires a Lock remembering `parameter_manager.gone`. The API itself cannot reach that state, since removing a Target removes its Locks. Raised by both test reviewers and plan-checker-qwen. + Orchestrator run: 57 in the named files, 219 in the full suite. +- `3481f56` Fix round 3, from Marcos's decision on a finding held back in round 0. reviewer-qwen showed live that `pm.q02.remove_parameter("y")`, which a client can call over the wire because every public submodule method is proxied, skipped the root's cleanup: the Lock stayed in `list_locks()` and the Follower kept answering the deleted Target's last value through `_target`. Marcos chose to delegate to the root. Each Parameter Group now has `_root` and `_path_prefix`, set in `_get_parent(create_parent=True)` through a `_root_for_new_groups()` hook that the root overrides to return itself. With `_root` set, `ParameterGroup.add_parameter`/`remove_parameter` forward to the root with ``. The plain creation path now ends in the new `_add_own_parameter`, so the root's own call does not route again, and a standalone group behaves as before. This also settles the 1.1 loose end: `pm.q01.add_parameter` now creates a `ManagedParameter` with the right `path`. `test_lock_with_a_plain_group_parameter` builds its plain `Parameter` through `_add_own_parameter`, the only way left. Four new tests: `test_group_remove_parameter_delegates_to_the_root`, `test_group_add_parameter_creates_a_managed_parameter`, `test_nested_group_add_parameter_creates_a_managed_parameter` and `test_standalone_group_keeps_plain_parameters`. In re-review all six approved; reviewer-qwen confirmed its finding fixed, and reviewer-glm checked the routing at depth 3 and found no recursion. Orchestrator run: 48 in `test_pm_locks.py`, 61 in the named files, 223 in the full suite. + +### Dropped findings +- `get_lock`, `list_locks` and `followers_of` quote their return annotations although `PMLockBluePrint` is imported (reviewer-glm, reviewer-qwen, nit) → not sent, style only. The quotes are harmless; 0.2's rule to quote annotations for the proxy exec is a reason to keep them. +- `toggle_lock` checks the parameter and its Lock, then calls `unlock`/`relock`, which check again (reviewer-glm, nit) → not sent. +- No test that a `remove_parameter` on an unknown path leaves every Lock in place, and no Lock at depth 2 in the Lock API tests (test-reviewer-qwen, nits) → not sent; the orchestrator suggested folding them into 1.3's tests. `3481f56`'s nested-group test now locks at depth 2, but `list_locks`/`followers_of` keys at that depth are still unchecked. +- `test_calls_without_a_lock_raise_naming_the_path` matches without `re.escape` (plan-checker-qwen, nit) → not sent. +- Re-review nits, not sent: `lock("nope", "nope")` names the same path twice (plan-checker-glm), and the "`parameter_class` defaults to `qcodes.Parameter`" line in `ParameterGroup.add_parameter`'s docstring now describes only standalone groups (plan-checker-qwen; reviewer-qwen also noticed it). + +### Questions to Marcos +- `unlock` on an already unlocked Lock and `relock` on an already locked one: raise, or do nothing? → Do nothing, but log at INFO level, so "the UI should be able to tell and do the right thing". Done in `c636f5c`. +- How should `add_parameter`/`remove_parameter` called directly on a Parameter Group behave (reviewer-qwen's held finding)? → Delegate to the root: groups hold a reference to the root Parameter Manager and route to it, and keep no Lock logic themselves (D15). Done in `3481f56`. + +### Loose ends +- A group-level `remove_parameter` with `cleanup=True` now runs `remove_empty_submodules` on the root, so it also removes empty groups elsewhere in the tree (reviewer-qwen, noted, not a finding). This is the same as the root-level call, and empty groups hold no state. +- A Parameter Group attached to a manager by foreign code through a direct `add_submodule` keeps `_root = None` and skips routing (reviewer-glm; not reachable over the wire). reviewer-glm suggested a line in TEST_AUDIT.md. The orchestrator had also said to note the plain-`Parameter` case for TEST_AUDIT/1.3, but nothing for 1.2 is in `TEST_AUDIT.md` or `orchestration/RUNS.md` yet. +- The unused `full_name` accumulator in `_get_parent`, already noted after 0.1, is still there (reviewer-glm). +- For 1.3: `list_locks()` returns `Dict[str, PMLockBluePrint]`, and reviewer-glm found that `bluePrintToDict` already recurses into dict values, so the wire round-trip looks supported. + +### Process notes +- The coder's `ask` timed out on its side before Marcos's answer came back, so it built the behaviour Marcos then overruled, which cost the pre-review fix round. +- While moving the coder's permission-dialog selection, the orchestrator sent Shift+Tab, which is opencode's agent switcher, and the coder terminal switched to the "Build" agent, which has no deny rules. The turn that was running kept running as Coder. The orchestrator pressed Tab to switch back and checked the bottom bar before the next dispatch. The first Enter on that dialog had also opened an "Always allow" confirm, which was cancelled; the prompt helper should send left-arrows before Enter. +- Two rejected permission requests in round 0: reviewer-qwen asked for `/tmp` and reviewer-glm tried to write a scratch file to opencode's temp dir. Both were told to use `orchestration/1.2/`, and they ran and deleted their scratch scripts there in both rounds. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 7f42a4a..774f46d 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -454,7 +454,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session _class_type="PMLockBluePrint")` in `blueprints.py`. Tests: `test_pm_locks.py` unit part with two standalone `ManagedParameter`s (no manager): get redirect, set raises with the right message, unlocked exposes own value, snapshot values, cache untouched by locking. -- [ ] **1.2 Lock API on `ParameterManager`.** `lock`, `unlock`, `relock`, `toggle_lock`, +- [x] **1.2 Lock API on `ParameterManager`.** `lock`, `unlock`, `relock`, `toggle_lock`, `remove_lock`, `get_lock`, `list_locks`, `followers_of` (D9), all validate-then-mutate: unknown paths, self-lock, and cycles (walking Targets regardless of state, D7) raise with messages naming the paths. `lock` on a parameter with an existing Lock re-targets (after From d5f0c63ca48edab06128e09adc133e01f385a89c Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 14:21:28 -0500 Subject: [PATCH 030/107] 1.3: pm-lock-update broadcasts from the Lock API and proxy round-trip of Lock blueprints --- src/instrumentserver/blueprints.py | 12 +- src/instrumentserver/params.py | 61 +++++- test/pytest/conftest.py | 62 +++++- test/pytest/test_broadcaster.py | 47 +---- test/pytest/test_param_manager.py | 43 +--- test/pytest/test_pm_locks.py | 327 ++++++++++++++++++++++++++++- 6 files changed, 449 insertions(+), 103 deletions(-) diff --git a/src/instrumentserver/blueprints.py b/src/instrumentserver/blueprints.py index 7eb3a2a..d83bc8c 100644 --- a/src/instrumentserver/blueprints.py +++ b/src/instrumentserver/blueprints.py @@ -365,11 +365,16 @@ def bluePrintFromInstrumentModule( @dataclass class ParameterBroadcastBluePrint: - """Blueprint to broadcast parameter changes.""" + """Blueprint to broadcast parameter changes. + + ``value`` carries whatever payload the action needs: a plain value for + the parameter actions and a :class:`PMLockBluePrint` for + ``pm-lock-update`` (``None`` when a Lock was removed). + """ name: str action: str - value: int | None = None + value: Any | None = None unit: str = "" _class_type: str = "ParameterBroadcastBluePrint" @@ -890,6 +895,9 @@ def dict_to_serialized_dict( serialized_iterable = dict_to_serialized_dict(dct=value) converted_dict[name] = serialized_iterable + elif isinstance(value, get_args(BluePrintType)): + converted_dict[name] = bluePrintToDict(value) + # Enum/IntFlag members are treated as scalars. This must come before # the generic Iterable check: since Python 3.11 a Flag member is # iterable and a single-bit member iterates to itself, which would diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 7d3bd4b..0838920 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -13,7 +13,7 @@ from . import serialize from .base import Broadcaster -from .blueprints import PMLockBluePrint +from .blueprints import PM_LOCK_UPDATE, PMLockBluePrint, ParameterBroadcastBluePrint logger = logging.getLogger(__name__) @@ -385,8 +385,10 @@ class ParameterManager(Broadcaster, ParameterGroup): It implements the Broadcaster contract, so the Server can register itself as a broadcast sink when the Parameter Manager joins the - Station. Nothing is broadcast yet; the Lock and Type features will - emit through it. + Station. Every Lock method that changes a Lock emits one + ``pm-lock-update`` Broadcast per affected Follower (D10), and + ``remove_parameter`` emits them for the Locks that deleting a Target + drops; Type editing will emit through it too. For the parameter manager to recognize other profiles in disk, the profile filename needs to start with 'parameter_manager-' @@ -449,7 +451,8 @@ def _root_for_new_groups(self) -> "ParameterManager": def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: """Remove a parameter, first removing every Lock whose Target it is (ADR-0002): the Followers become plain parameters and answer ``get`` - with their own values again. + with their own values again. One ``pm-lock-update`` Broadcast with a + ``None`` value is emitted per dropped Lock (D10). Same signature and deletion behaviour as :meth:`ParameterGroup.remove_parameter`; the path is relative to @@ -466,12 +469,16 @@ def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: # locked or not: an unlocked Lock must not keep remembering a # Target that no longer exists. target_full = self._full_path(param_name) + dropped_followers: List[str] = [] for rel_path, param in self._iter_params(): lock = getattr(param, "lock", None) if lock is not None and lock.target == target_full: assert isinstance(param, ManagedParameter) param.lock = None param._target = None + dropped_followers.append(rel_path) + for rel_path in dropped_followers: + self._broadcast_lock_update(rel_path, None) super().remove_parameter(param_name, cleanup) @@ -486,6 +493,28 @@ def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: # by name are dotted paths relative to this Parameter Manager; only # ``PMLockBluePrint.target`` and the stored Lock Target use the full # form, as :attr:`ManagedParameter.path` does. + # + # Every method that changes a Lock emits one ``pm-lock-update`` + # Broadcast per affected Follower (D10), through :meth:`broadcast` of + # the Broadcaster contract. Broadcasts that only report state are + # emitted after the change; failed validations and the logged no-op + # paths (unlock on an unlocked, relock on a locked Lock) emit nothing. + + def _broadcast_lock_update( + self, follower_path: str, lock: "PMLockBluePrint | None" + ) -> None: + """Emit one ``pm-lock-update`` Broadcast about the Follower at + ``follower_path`` (relative to this Parameter Manager): the payload + is its :class:`PMLockBluePrint`, or ``None`` when its Lock was + removed (D10). With no sink registered, :meth:`broadcast` is a + no-op, so standalone use of the Parameter Manager emits nothing.""" + self.broadcast( + ParameterBroadcastBluePrint( + name=self._full_path(follower_path), + action=PM_LOCK_UPDATE, + value=lock, + ) + ) def _full_path(self, relative_name: str) -> str: """The full dotted path (with the instrument name) of a path @@ -563,7 +592,9 @@ def lock(self, name: str, target: str) -> None: exist (both paths are checked before one error is raised), the Follower cannot carry a Lock, the Lock would be a self-lock, or it would close a cycle (walking Targets regardless of locked/unlocked - state, D7). + state, D7). On success emits one ``pm-lock-update`` Broadcast + naming the Follower with its new Lock (D10); a failed validation + emits nothing. :param name: path of the Follower. :param target: path of the Target. @@ -587,6 +618,7 @@ def lock(self, name: str, target: str) -> None: self._check_lock_allowed(follower_full, target_full) follower._target = target_param follower.lock = PMLockBluePrint(target=target_full, locked=True) + self._broadcast_lock_update(name, follower.lock) def unlock(self, name: str) -> None: """Unlock the Lock of the parameter at ``name`` (dotted path @@ -594,7 +626,9 @@ def unlock(self, name: str) -> None: Target but answers ``get`` with its own value again (D5). Raises ``ValueError`` naming the path when the parameter does not exist or carries no Lock; unlocking an already unlocked Lock does - nothing and logs at INFO level.""" + nothing and logs at INFO level. On a state change emits one + ``pm-lock-update`` Broadcast carrying the unlocked Lock (D10); + the no-op path emits nothing.""" param = self._resolve_param(name) lock = self._require_lock(param, name) if not lock.locked: @@ -603,6 +637,7 @@ def unlock(self, name: str) -> None: ) return lock.locked = False + self._broadcast_lock_update(name, lock) def relock(self, name: str) -> None: """Lock the Lock of the parameter at ``name`` (dotted path @@ -610,7 +645,9 @@ def relock(self, name: str) -> None: (D5). Raises ``ValueError`` naming the paths when the parameter does not exist, carries no Lock, or when the remembered Target is gone or locking to it would close a cycle (D7); relocking an - already locked Lock does nothing and logs at INFO level.""" + already locked Lock does nothing and logs at INFO level. On a + state change emits one ``pm-lock-update`` Broadcast carrying the + locked Lock (D10); the no-op path emits nothing.""" param = self._resolve_param(name) lock = self._require_lock(param, name) follower_full = self._full_path(name) @@ -627,13 +664,16 @@ def relock(self, name: str) -> None: assert isinstance(param, ManagedParameter) param._target = target_param lock.locked = True + self._broadcast_lock_update(name, lock) def toggle_lock(self, name: str) -> None: """Toggle the Lock of the parameter at ``name`` (dotted path relative to this Parameter Manager): locked becomes unlocked and unlocked becomes locked again (D5). Raises ``ValueError`` naming the path when the parameter does not exist or carries no Lock, and - like :meth:`relock` when locking back would close a cycle.""" + like :meth:`relock` when locking back would close a cycle. Emits + one ``pm-lock-update`` Broadcast through :meth:`unlock` / + :meth:`relock`, which carry out the change (D10).""" param = self._resolve_param(name) lock = self._require_lock(param, name) if lock.locked: @@ -646,12 +686,15 @@ def remove_lock(self, name: str) -> None: relative to this Parameter Manager) entirely: the Target is forgotten and the parameter behaves as a plain parameter again (D5). Raises ``ValueError`` naming the path when the parameter - does not exist or carries no Lock.""" + does not exist or carries no Lock. Emits one ``pm-lock-update`` + Broadcast with a ``None`` value for the Follower whose Lock was + removed (D10).""" param = self._resolve_param(name) self._require_lock(param, name) assert isinstance(param, ManagedParameter) param.lock = None param._target = None + self._broadcast_lock_update(name, None) def get_lock(self, name: str) -> "PMLockBluePrint | None": """The Lock of the parameter at ``name`` (dotted path relative to diff --git a/test/pytest/conftest.py b/test/pytest/conftest.py index c81175e..75a2c9f 100644 --- a/test/pytest/conftest.py +++ b/test/pytest/conftest.py @@ -1,11 +1,14 @@ import random import socket +import time +from contextlib import contextmanager import pytest # type: ignore[import-not-found] import qcodes as qc +from instrumentserver import QtCore from instrumentserver.client.core import BaseClient -from instrumentserver.client.proxy import Client +from instrumentserver.client.proxy import Client, SubClient from instrumentserver.server.core import startServer @@ -106,3 +109,60 @@ def param_manager(cli): "parameter_manager", "instrumentserver.params.ParameterManager" ) return cli, params + + +# --------------------------------------------------------------------------- +# Broadcast capture helpers, shared by the proxy tests (test_broadcaster.py, +# test_param_manager.py, test_pm_locks.py). They are exposed as fixtures so +# test modules do not import from conftest directly. +# --------------------------------------------------------------------------- + + +@contextmanager +def _capture_broadcasts(instruments, sub_port): + """Run a SubClient on its own QThread and collect the Broadcasts it receives. + + Mirrors the pattern of ``test/docs_verification/helpers.py``, but takes + the Broadcast port explicitly so tests pass the ``server_port`` fixture's + Broadcast port (``server_port + 1``). + """ + received = [] + sub = SubClient(instruments=instruments, sub_host="localhost", sub_port=sub_port) + sub.update.connect(received.append, QtCore.Qt.DirectConnection) + thread = QtCore.QThread() + sub.moveToThread(thread) + thread.started.connect(sub.connect) + sub.finished.connect(thread.quit) + thread.start() + # PUB/SUB slow joiner: let the SUB socket connect before Broadcasts fire. + time.sleep(0.3) + try: + yield received + finally: + sub.stop() + thread.wait(2000) + thread.deleteLater() + + +def _wait_for_broadcasts(received, n=1, timeout=5.0): + """Block until at least ``n`` Broadcasts arrived, or fail with a report.""" + deadline = time.monotonic() + timeout + while len(received) < n: + if time.monotonic() > deadline: + raise AssertionError( + f"Expected {n} Broadcast(s) within {timeout}s, " + f"got {len(received)}: {received!r}" + ) + time.sleep(0.05) + + +@pytest.fixture(scope="session") +def capture_broadcasts(): + """The :func:`_capture_broadcasts` context manager factory.""" + return _capture_broadcasts + + +@pytest.fixture(scope="session") +def wait_for_broadcasts(): + """The :func:`_wait_for_broadcasts` wait helper.""" + return _wait_for_broadcasts diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py index efa73a4..f3237af 100644 --- a/test/pytest/test_broadcaster.py +++ b/test/pytest/test_broadcaster.py @@ -10,12 +10,9 @@ """ import logging -import time -from contextlib import contextmanager import qcodes as qc -from instrumentserver import QtCore from instrumentserver.base import Broadcaster from instrumentserver.blueprints import ( PARAMETER_CALL, @@ -26,7 +23,6 @@ PM_TYPE_UPDATE, ParameterBroadcastBluePrint, ) -from instrumentserver.client.proxy import SubClient from instrumentserver.config import loadConfig from instrumentserver.params import ParameterManager from instrumentserver.server.core import StationServer @@ -193,46 +189,9 @@ def test_parameter_manager_broadcast_reaches_sink(tmp_path, monkeypatch): ) -@contextmanager -def capture_broadcasts(instruments, sub_port): - """Run a SubClient on its own QThread and collect the Broadcasts it receives. - - Mirrors the pattern of ``test/docs_verification/helpers.py``, but takes the - Broadcast port from the ``server_port`` fixture instead of the default. - """ - received = [] - sub = SubClient( - instruments=instruments, sub_host="localhost", sub_port=sub_port - ) - sub.update.connect(received.append, QtCore.Qt.DirectConnection) - thread = QtCore.QThread() - sub.moveToThread(thread) - thread.started.connect(sub.connect) - sub.finished.connect(thread.quit) - thread.start() - # PUB/SUB slow joiner: let the SUB socket connect before Broadcasts fire. - time.sleep(0.3) - try: - yield received - finally: - sub.stop() - thread.wait(2000) - thread.deleteLater() - - -def wait_for_broadcasts(received, n=1, timeout=5.0): - """Block until at least ``n`` Broadcasts arrived, or fail with a report.""" - deadline = time.monotonic() + timeout - while len(received) < n: - if time.monotonic() > deadline: - raise AssertionError( - f"Expected {n} Broadcast(s) within {timeout}s, " - f"got {len(received)}: {received!r}" - ) - time.sleep(0.05) - - -def test_created_broadcaster_instrument_reaches_subclient(cli, start_server, server_port): +def test_created_broadcaster_instrument_reaches_subclient( + cli, start_server, server_port, capture_broadcasts, wait_for_broadcasts +): """A Broadcaster instrument created through a client has the Server as a sink, and a method call that emits a blueprint arrives at a SubClient.""" inst = cli.find_or_create_instrument("bcaster", BROADCASTER_INSTRUMENT_CLASS) diff --git a/test/pytest/test_param_manager.py b/test/pytest/test_param_manager.py index b8b1796..f8cd05b 100644 --- a/test/pytest/test_param_manager.py +++ b/test/pytest/test_param_manager.py @@ -1,10 +1,6 @@ import json -import time -from contextlib import contextmanager -from instrumentserver import QtCore from instrumentserver.blueprints import ParameterBroadcastBluePrint -from instrumentserver.client.proxy import SubClient from instrumentserver.params import ParameterGroup, ParameterManager @@ -60,45 +56,8 @@ def test_proxy_add_remove_parameter(param_manager): assert "probe_param" not in params.parameters -@contextmanager -def capture_broadcasts(instruments, sub_port): - """Run a SubClient on its own QThread and collect the Broadcasts it receives. - - Follows the capture pattern of ``test_broadcaster.py``; takes the - Broadcast port from the ``server_port`` fixture instead of the default. - """ - received = [] - sub = SubClient(instruments=instruments, sub_host="localhost", sub_port=sub_port) - sub.update.connect(received.append, QtCore.Qt.DirectConnection) - thread = QtCore.QThread() - sub.moveToThread(thread) - thread.started.connect(sub.connect) - sub.finished.connect(thread.quit) - thread.start() - # PUB/SUB slow joiner: let the SUB socket connect before Broadcasts fire. - time.sleep(0.3) - try: - yield received - finally: - sub.stop() - thread.wait(2000) - thread.deleteLater() - - -def wait_for_broadcasts(received, n=1, timeout=5.0): - """Block until at least ``n`` Broadcasts arrived, or fail with a report.""" - deadline = time.monotonic() + timeout - while len(received) < n: - if time.monotonic() > deadline: - raise AssertionError( - f"Expected {n} Broadcast(s) within {timeout}s, " - f"got {len(received)}: {received!r}" - ) - time.sleep(0.05) - - def test_add_parameter_without_initial_value_succeeds_and_broadcasts( - param_manager, server_port + param_manager, server_port, capture_broadcasts, wait_for_broadcasts ): """Calling ``add_parameter("x")`` with no initial_value and no unit over the wire succeeds and Broadcasts the creation with an empty payload diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 44fe07e..56add85 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -1,12 +1,16 @@ -"""Unit tests for ``ManagedParameter`` and the Lock API (plan tasks 1.1 -and 1.2). +"""Tests for ``ManagedParameter`` and the Lock API (plan tasks 1.1–1.3). The first part wires two standalone ManagedParameters (a Target and a Follower) by hand, with no Parameter Manager involved. The second part exercises the Lock API on a local Parameter Manager (``lock``, ``unlock``, ``relock``, ``toggle_lock``, ``remove_lock``, ``get_lock``, ``list_locks``, -``followers_of``, and the ``remove_parameter`` Lock cleanup). Its -Broadcasts are task 1.3. +``followers_of``, and the ``remove_parameter`` Lock cleanup). The third +part checks the ``pm-lock-update`` Broadcasts the Lock methods emit +(D10), on a local Parameter Manager with a Broadcast sink. The last part +exercises the Lock API through a client proxy against a live Server: every +method callable over the wire, ``get_lock``/``list_locks`` deserialising +to ``PMLockBluePrint``, the pull-on-get value over the wire, and a +SubClient receiving the Broadcasts. """ import logging @@ -14,7 +18,11 @@ import pytest -from instrumentserver.blueprints import PMLockBluePrint +from instrumentserver.blueprints import ( + PM_LOCK_UPDATE, + PMLockBluePrint, + ParameterBroadcastBluePrint, +) from instrumentserver.params import ( ManagedParameter, ParameterGroup, @@ -720,3 +728,312 @@ def test_target_paths_must_be_relative_to_the_manager(pm): pm.lock("q01.x", "other.z") assert pm.list_locks() == {} + + +# --------------------------------------------------------------------------- +# pm-lock-update Broadcasts (plan task 1.3, D10) +# +# One Broadcast per affected Follower, name = full Follower path, value = +# its PMLockBluePrint, or None when its Lock was removed. Emitted by every +# Lock method that changes a Lock and by remove_parameter when deleting a +# Target drops Locks; the logged no-op paths and failed validations emit +# nothing (decided during 1.3). +# --------------------------------------------------------------------------- + + +@pytest.fixture +def pm_with_sink(pm): + """The Lock API fixture with a Broadcast sink attached, recording + every Broadcast the Parameter Manager emits.""" + received = [] + pm.add_broadcast_sink(received.append) + return pm, received + + +def test_lock_emits_one_pm_lock_update_naming_the_follower(pm_with_sink): + pm, received = pm_with_sink + + pm.lock("q01.x", "q01Data.IF") + + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + +def test_unlock_emits_pm_lock_update_with_the_unlocked_lock(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.unlock("q01.x") + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + + +def test_relock_emits_pm_lock_update_with_the_locked_lock(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + received.clear() + + pm.relock("q01.x") + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + +def test_toggle_lock_emits_one_broadcast_per_state_change(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.toggle_lock("q01.x") + assert len(received) == 1 + assert received[0].name == "parameter_manager.q01.x" + assert received[0].value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + + pm.toggle_lock("q01.x") + assert len(received) == 2 + assert received[1].value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + +def test_remove_lock_emits_pm_lock_update_with_none(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.remove_lock("q01.x") + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value is None + + +def test_noop_unlock_and_relock_emit_nothing(pm_with_sink, caplog): + # decided during 1.3: the logged no-op paths affect no Follower, + # so they emit no Broadcast + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") # locked + pm.lock("q02.x", "q01Data.IF") # locked + pm.unlock("q02.x") # unlocked + received.clear() + + with caplog.at_level(logging.INFO): + pm.relock("q01.x") # already locked: no-op + pm.unlock("q02.x") # already unlocked: no-op + + assert received == [] + assert "already locked" in caplog.text + assert "already unlocked" in caplog.text + + +def test_failed_lock_validations_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + + # validate-then-mutate: a refused call must not broadcast either + with pytest.raises(ValueError): + pm.lock("nope", "q01Data.IF") + with pytest.raises(ValueError): + pm.lock("q01.x", "q01.x") # self-lock + assert received == [] + + pm.lock("q01.x", "q01Data.IF") + received.clear() + with pytest.raises(ValueError): + pm.lock("q01Data.IF", "q01.x") # would close a cycle + with pytest.raises(ValueError): + pm.unlock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.remove_lock("q02.y") # no Lock + + assert received == [] + + +def test_remove_parameter_emits_one_none_per_dropped_lock(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01Data.IF") + pm.unlock("q02.x") # an unlocked Lock is dropped all the same + received.clear() + + pm.remove_parameter("q01Data.IF") + + assert len(received) == 2 + names = {bp.name for bp in received} + assert names == {"parameter_manager.q01.x", "parameter_manager.q02.x"} + for bp in received: + assert bp.action == PM_LOCK_UPDATE + assert bp.value is None + + +def test_remove_parameter_without_dropped_locks_emits_nothing(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") # q01.x is a Follower, not a Target + received.clear() + + # removing a Follower drops no Locks: its own Lock disappears with it, + # and the parameter-deletion Broadcast is the Server's business + pm.remove_parameter("q01.x") + assert received == [] + + pm.remove_parameter("q02.y") # unrelated parameter + assert received == [] + + +# --------------------------------------------------------------------------- +# Lock API through a client proxy against a live Server (plan task 1.3) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3), so every Lock method call over the wire also emits its +# pm-lock-update on the PUB socket. The server-side Parameter Manager is +# shared by all tests of this module, so every test removes the parameters +# it created again. +# --------------------------------------------------------------------------- + +PROXY_FOLLOWER = "q02.x" +PROXY_TARGET = "q01Data.IF" + + +def _add_proxy_params(params): + """Create the two parameters the proxy Lock tests use, replacing any + leftovers from an earlier test of this module.""" + _remove_proxy_params(params) + params.add_parameter(PROXY_FOLLOWER, initial_value=3, unit="V") + params.add_parameter(PROXY_TARGET, initial_value=10, unit="Hz") + + +def _remove_proxy_params(params): + for name in (PROXY_FOLLOWER, PROXY_TARGET): + if params.has_param(name): + params.remove_parameter(name) + + +def test_every_lock_method_is_callable_through_the_proxy(param_manager): + cli, params = param_manager + _add_proxy_params(params) + try: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + assert params.get_lock(PROXY_FOLLOWER) == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + params.unlock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is False + + params.relock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is True + + params.toggle_lock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is False + params.toggle_lock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is True + + assert params.followers_of(PROXY_TARGET) == [PROXY_FOLLOWER] + assert list(params.list_locks()) == [PROXY_FOLLOWER] + + params.remove_lock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER) is None + assert params.list_locks() == {} + assert params.followers_of(PROXY_TARGET) == [] + finally: + _remove_proxy_params(params) + + +def test_get_lock_and_list_locks_deserialise_to_pm_lock_blueprint(param_manager): + cli, params = param_manager + _add_proxy_params(params) + try: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + + lock_bp = params.get_lock(PROXY_FOLLOWER) + assert isinstance(lock_bp, PMLockBluePrint) + assert lock_bp == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + locks = params.list_locks() + assert isinstance(locks, dict) + assert isinstance(locks[PROXY_FOLLOWER], PMLockBluePrint) + assert locks == { + PROXY_FOLLOWER: PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + } + finally: + _remove_proxy_params(params) + + +def test_locked_follower_answers_get_with_the_target_value_over_the_wire( + param_manager, +): + cli, params = param_manager + _add_proxy_params(params) + try: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + + # the named check: pm.q02.x() returns the Target's value + assert params.q02.x() == 10 + + # pull on get: changing the Target is enough, nothing is pushed + params.q01Data.IF.set(20) + assert params.q02.x() == 20 + + # unlocking exposes the Follower's own value again + params.unlock(PROXY_FOLLOWER) + assert params.q02.x() == 3 + finally: + _remove_proxy_params(params) + + +def test_subclient_receives_pm_lock_update_and_none_after_remove_lock( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _add_proxy_params(params) + try: + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + wait_for_broadcasts(received) + + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.q02.x" + assert bp.action == PM_LOCK_UPDATE + assert isinstance(bp.value, PMLockBluePrint) + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + params.remove_lock(PROXY_FOLLOWER) + wait_for_broadcasts(received, n=2) + + assert len(received) == 2 + removed = received[1] + assert removed.name == "parameter_manager.q02.x" + assert removed.action == PM_LOCK_UPDATE + assert removed.value is None + finally: + _remove_proxy_params(params) From d1a4332528f337b9896e7a15ceafcdd1978df2b2 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 14:41:16 -0500 Subject: [PATCH 031/107] 1.3: fix from review round 1: ruff import order, snapshot Lock broadcast payloads, re-target and validation tests --- src/instrumentserver/params.py | 24 +++++++++++--- test/pytest/test_pm_locks.py | 57 ++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 0838920..c3e6dfe 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -13,7 +13,11 @@ from . import serialize from .base import Broadcaster -from .blueprints import PM_LOCK_UPDATE, PMLockBluePrint, ParameterBroadcastBluePrint +from .blueprints import ( + PM_LOCK_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, +) logger = logging.getLogger(__name__) @@ -618,7 +622,11 @@ def lock(self, name: str, target: str) -> None: self._check_lock_allowed(follower_full, target_full) follower._target = target_param follower.lock = PMLockBluePrint(target=target_full, locked=True) - self._broadcast_lock_update(name, follower.lock) + # a snapshot, not the stored record: sinks must not see the payload + # change when the Lock is toggled or re-targeted later + self._broadcast_lock_update( + name, PMLockBluePrint(target=target_full, locked=True) + ) def unlock(self, name: str) -> None: """Unlock the Lock of the parameter at ``name`` (dotted path @@ -637,7 +645,11 @@ def unlock(self, name: str) -> None: ) return lock.locked = False - self._broadcast_lock_update(name, lock) + # a snapshot, not the live record: sinks must not see the payload + # change when the Lock is toggled again + self._broadcast_lock_update( + name, PMLockBluePrint(target=lock.target, locked=lock.locked) + ) def relock(self, name: str) -> None: """Lock the Lock of the parameter at ``name`` (dotted path @@ -664,7 +676,11 @@ def relock(self, name: str) -> None: assert isinstance(param, ManagedParameter) param._target = target_param lock.locked = True - self._broadcast_lock_update(name, lock) + # a snapshot, not the live record: sinks must not see the payload + # change when the Lock is toggled again + self._broadcast_lock_update( + name, PMLockBluePrint(target=lock.target, locked=lock.locked) + ) def toggle_lock(self, name: str) -> None: """Toggle the Lock of the parameter at ``name`` (dotted path diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 56add85..a617fc1 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -20,8 +20,8 @@ from instrumentserver.blueprints import ( PM_LOCK_UPDATE, - PMLockBluePrint, ParameterBroadcastBluePrint, + PMLockBluePrint, ) from instrumentserver.params import ( ManagedParameter, @@ -765,6 +765,23 @@ def test_lock_emits_one_pm_lock_update_naming_the_follower(pm_with_sink): ) +def test_re_targeting_a_lock_emits_one_pm_lock_update_with_the_new_target( + pm_with_sink, +): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.lock("q01.x", "q01.y") # re-targets the existing Lock (D9) + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint(target="parameter_manager.q01.y", locked=True) + assert pm.get("q01.x") == 2 # the Follower now pulls from q01.y + + def test_unlock_emits_pm_lock_update_with_the_unlocked_lock(pm_with_sink): pm, received = pm_with_sink pm.lock("q01.x", "q01Data.IF") @@ -817,6 +834,28 @@ def test_toggle_lock_emits_one_broadcast_per_state_change(pm_with_sink): ) +def test_broadcast_payloads_are_independent_of_the_stored_lock(pm_with_sink): + # unlock and relock broadcast a snapshot, not the parameter's own Lock + # record: a sink that keeps payloads must still see each Broadcast's + # state at emit time after the Lock is toggled again + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.toggle_lock("q01.x") # unlock + pm.toggle_lock("q01.x") # lock again + + assert len(received) == 2 + assert received[0].value.locked is False + assert received[1].value.locked is True + assert received[0].value.locked != received[1].value.locked + assert received[0].value is not received[1].value + # the stored record is untouched by the broadcasting + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + def test_remove_lock_emits_pm_lock_update_with_none(pm_with_sink): pm, received = pm_with_sink pm.lock("q01.x", "q01Data.IF") @@ -857,16 +896,30 @@ def test_failed_lock_validations_emit_nothing(pm_with_sink): pm.lock("nope", "q01Data.IF") with pytest.raises(ValueError): pm.lock("q01.x", "q01.x") # self-lock + with pytest.raises(ValueError): + pm.relock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.toggle_lock("q02.y") # no Lock assert received == [] pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") received.clear() with pytest.raises(ValueError): pm.lock("q01Data.IF", "q01.x") # would close a cycle with pytest.raises(ValueError): pm.unlock("q02.y") # no Lock with pytest.raises(ValueError): - pm.remove_lock("q02.y") # no Lock + pm.relock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.toggle_lock("q02.y") # no Lock + # a relock that would close a cycle is refused as well (D7) + pm.parameter("q01Data.IF")._target = pm.parameter("q01.x") + pm.parameter("q01Data.IF").lock = PMLockBluePrint( + target="parameter_manager.q01.x", locked=True + ) + with pytest.raises(ValueError, match="cycle in Lock targets"): + pm.relock("q01.x") assert received == [] From 7ff0d711ce4432bc3781ef4d38c6e6232db55ee9 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 14:50:45 -0500 Subject: [PATCH 032/107] 1.3: fix from review round 2: restore the failed remove_lock no-emission assertion --- test/pytest/test_pm_locks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index a617fc1..20db25e 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -913,6 +913,8 @@ def test_failed_lock_validations_emit_nothing(pm_with_sink): pm.relock("q02.y") # no Lock with pytest.raises(ValueError): pm.toggle_lock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.remove_lock("q02.y") # no Lock # a relock that would close a cycle is refused as well (D7) pm.parameter("q01Data.IF")._target = pm.parameter("q01.x") pm.parameter("q01Data.IF").lock = PMLockBluePrint( From a4cc6e48f8216610bf32c6d93916df2597bdf18f Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 14:59:08 -0500 Subject: [PATCH 033/107] 1.3: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 37 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 559f302..8d0c3dd 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -201,3 +201,40 @@ Two of the three pre-existing defects listed in D24 are fixed. In `src/instrumen - The coder's `ask` timed out on its side before Marcos's answer came back, so it built the behaviour Marcos then overruled, which cost the pre-review fix round. - While moving the coder's permission-dialog selection, the orchestrator sent Shift+Tab, which is opencode's agent switcher, and the coder terminal switched to the "Build" agent, which has no deny rules. The turn that was running kept running as Coder. The orchestrator pressed Tab to switch back and checked the bottom bar before the next dispatch. The first Enter on that dialog had also opened an "Always allow" confirm, which was cancelled; the prompt helper should send left-arrows before Enter. - Two rejected permission requests in round 0: reviewer-qwen asked for `/tmp` and reviewer-glm tried to write a scratch file to opencode's temp dir. Both were told to use `orchestration/1.2/`, and they ran and deleted their scratch scripts there in both rounds. + +## 1.3 `pm-lock-update` and proxy round-trip — 2026-09-24 + +Every `ParameterManager` Lock method that changes a Lock now emits one `pm-lock-update` Broadcast per affected Follower (D10) through the new private helper `_broadcast_lock_update(follower_path, lock)`. `name` is the full Follower path, and `value` is a `PMLockBluePrint`, or `None` when the Lock was removed. `lock`, `unlock`, `relock` and `remove_lock` emit directly, and `toggle_lock` emits through `unlock`/`relock`. The `remove_parameter` cleanup emits one `None` per dropped Lock, locked or unlocked, before it deletes the Target. Read-only methods, failed validations and the INFO-logged no-op paths emit nothing. `test/pytest/test_pm_locks.py` grew from 48 to 63 tests: server-free sink tests on a `pm_with_sink` fixture, plus the four proxy tests the task names, which run on the `param_manager` fixture. + +### Commit by commit +- `d5f0c63` The emissions, docstrings stating when each method emits, and 13 tests. The proxy tests are `test_every_lock_method_is_callable_through_the_proxy`, `test_get_lock_and_list_locks_deserialise_to_pm_lock_blueprint`, `test_locked_follower_answers_get_with_the_target_value_over_the_wire` (10 while locked, 20 after the Target is set over the wire, the own value 3 after `unlock`) and `test_subclient_receives_pm_lock_update_and_none_after_remove_lock`. The sink tests cover each method, `test_noop_unlock_and_relock_emit_nothing`, `test_failed_lock_validations_emit_nothing` and the two `remove_parameter` cases. The commit also made three changes the task text did not name. The orchestrator flagged them for the reviewers, and all six judged them in scope: + - `ParameterBroadcastBluePrint.value` is widened from `int | None` to `Any | None`, so it can carry a `PMLockBluePrint`. This settles the 1.1 loose end. + - `dict_to_serialized_dict` in `blueprints.py` gained a `BluePrintType` branch. Without it, the dict of blueprints that `list_locks` returns went over the wire as `str(value)` and could not be rebuilt. test-reviewer-glm confirmed that the `list_locks` isinstance assertion depends on this branch. + - `capture_broadcasts` / `wait_for_broadcasts` moved into `conftest.py` as session fixtures, and `test_broadcaster.py` and `test_param_manager.py` now use them. This is the third-copy move planned after 0.3 and 0.4. + Orchestrator run: 87 passed in the three named files, 236 in the full suite. +- `d1a4332` Fix from round 0, four items: + - Two ruff `I001` import-order errors, in `params.py` and `test_pm_locks.py`. reviewer-glm caught them (should-fix) by checking that the base was ruff-clean. The coder had reported "ruff clean", and the orchestrator confirmed that claim was wrong. + - `unlock` and `relock` broadcast the stored `PMLockBluePrint` itself, so a sink that kept payloads saw earlier ones change on the next toggle. reviewer-glm caught this too (should-fix). Both methods now broadcast a fresh copy. The coder did the same for `lock()`, which had the same aliasing, and said so in the commit. New test: `test_broadcast_payloads_are_independent_of_the_stored_lock`. + - `test_re_targeting_a_lock_emits_one_pm_lock_update_with_the_new_target`. Both test reviewers raised this as a nit, and it was sent because a fix round was happening anyway. + - `test_failed_lock_validations_emit_nothing` gained failed `relock` / `toggle_lock` calls on a parameter with no Lock, and a refused cycle `relock` on a hand-wired chain. An `unlock` comes first, so the already-locked no-op cannot swallow the raise. Both test reviewers raised this as a nit. The coder replaced the existing `remove_lock("q02.y")` case with a `relock` case instead of adding to it. + Orchestrator run: ruff clean, 89 in the named files, 238 in the full suite. +- `7ff0d71` Fix from round 1: puts the `pm.remove_lock("q02.y")` raise back into `test_failed_lock_validations_emit_nothing` (2 lines). test-reviewer-glm caught the regression (should-fix): after `d1a4332`, no test anywhere checked that a failed `remove_lock` emits nothing. All six reviewers approved in re-review, and test-reviewer-glm confirmed its finding fixed. Orchestrator run: 63 in `test_pm_locks.py`, 89 in the named files, 238 in the full suite. + +### Dropped findings +- The Lock API comment block's sentence "Broadcasts that only report state are emitted after the change" is muddled (reviewer-glm, nit) → not sent. It is still in `params.py`. +- `value: Any | None` is a redundant union (reviewer-qwen, nit) → not sent, style only. +- In `test_broadcast_payloads_are_independent_of_the_stored_lock`, the `!=` assertion adds nothing beyond the two `is` assertions above it (test-reviewer-qwen, round 1 nit) → not sent. The fix list had asked for that line word for word. + +### Questions to Marcos +- Should the INFO-logged no-op paths of `unlock`/`relock` emit `pm-lock-update`? The coder asked this before writing the code. The orchestrator answered from D10: no, since "one per affected Follower" and a no-op affects no Follower, so "emitted by every Lock method" means every method that changes a Lock. A test was required (`test_noop_unlock_and_relock_emit_nothing`). The orchestrator flagged the reading for Marcos in the run report, and plan-checker-glm asked for D10's wording to be read as amended by it. No answer from Marcos is recorded yet. + +### Loose ends +- Dict-valued responses such as `list_locks` still go over the wire as `str(dict)` plus the quote handling in `ServerResponse.__init__`. That works for lab paths but would break if a value ever contained a quote (reviewer-qwen). This was already the case before 1.3. reviewer-qwen suggested an entry in `TEST_AUDIT.md`, but none is there yet. +- `ModelParameters.updateParameter` in the GUI ignores the new `pm-lock-update` action without error until task 5.1 (reviewer-glm, reviewer-qwen). +- Only `lock` and `remove_lock` are tested with a `SubClient` over the wire. The `unlock`/`relock`/`toggle_lock` and `remove_parameter` emissions are covered by the sink tests only (test-reviewer-glm; this matches the task text). A group-level `pm.q01.remove_parameter(...)` emission is not asserted either, although it routes through the same root method. +- The 1.2 suggestions to add a depth-2 Lock and an unknown-path `remove_parameter` check to 1.3's tests were not taken up. + +### Process notes +- The coder tried to write a scratch file to opencode's temp dir outside the repo. It was rejected, and the coder used `orchestration/1.3/` and deleted the file afterwards. +- plan-checker-glm sent worker_done twice in round 0. Orca rejected the second one. +- No stalls or nudges were needed in any round. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 774f46d..9480cde 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -460,7 +460,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session messages naming the paths. `lock` on a parameter with an existing Lock re-targets (after the cycle check). `remove_parameter` removes every Lock whose Target is the removed parameter (D3) and then deletes. Chains behave per D7. Tests: `test_pm_locks.py` unit part. -- [ ] **1.3 `pm-lock-update` and proxy round-trip.** Emit `pm-lock-update` per D10 from every +- [x] **1.3 `pm-lock-update` and proxy round-trip.** Emit `pm-lock-update` per D10 from every Lock method and from the `remove_parameter` cleanup. Tests: `test_pm_locks.py` proxy part via the `param_manager` fixture: every method callable through the proxy; `get_lock` / `list_locks` deserialise to `PMLockBluePrint`; `pm.q02.x()` returns the Target's value over From 83afee72c6c2c3c4489f1176d6f1cbe2543276dc Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 15:27:39 -0500 Subject: [PATCH 034/107] 2.1: Type registry and definitions with PMTypeBluePrint, effective-set expansion and unit tests --- src/instrumentserver/blueprints.py | 26 +++ src/instrumentserver/params.py | 207 +++++++++++++++++- test/pytest/test_pm_types.py | 336 +++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 test/pytest/test_pm_types.py diff --git a/src/instrumentserver/blueprints.py b/src/instrumentserver/blueprints.py index d83bc8c..4b1a2bf 100644 --- a/src/instrumentserver/blueprints.py +++ b/src/instrumentserver/blueprints.py @@ -421,12 +421,38 @@ def toJson(self) -> Dict[str, Any]: return bluePrintToDict(self) +@dataclass +class PMTypeBluePrint: + """Blueprint of a Type of the Parameter Manager. + + ``parameters`` carries the Type's own entries as + ``{path: {default, unit, target}}``, where ``target`` is the Target of + the entry's Type Lock (``None`` when it has none). ``nested`` maps the + submodule name that requires a Nested Type to the nested Type's name. + ``effective`` is the computed effective parameter set: every own and + Nested Type entry path expanded under its submodule names, mapped to + the unit the entry declares and the Type that defines it + (``{path: {unit, from_type}}``). Returned by the Type API and sent as + the value of ``pm-type-update`` Broadcasts. + """ + + name: str + parameters: Dict[str, Dict[str, Any]] + nested: Dict[str, str] + effective: Dict[str, Dict[str, str]] + _class_type: str = "PMTypeBluePrint" + + def toJson(self) -> Dict[str, Any]: + return bluePrintToDict(self) + + BluePrintType = Union[ ParameterBluePrint, MethodBluePrint, InstrumentModuleBluePrint, ParameterBroadcastBluePrint, PMLockBluePrint, + PMTypeBluePrint, ] diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index c3e6dfe..4a7fa7a 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -2,6 +2,7 @@ import logging import os from collections.abc import Sequence +from dataclasses import dataclass, field from enum import Enum, auto, unique from functools import wraps from pathlib import Path @@ -17,6 +18,7 @@ PM_LOCK_UPDATE, ParameterBroadcastBluePrint, PMLockBluePrint, + PMTypeBluePrint, ) logger = logging.getLogger(__name__) @@ -376,6 +378,28 @@ def tolist(x: Dict[str, Any]) -> List[str]: return tolist(tree) +@dataclass +class _TypeEntry: + """One entry of a Type: a relative parameter path with its default + value and unit, plus the Target of the entry's Type Lock (``None`` + until a Type Lock is declared on it).""" + + default: Any = None + unit: str = "" + target: str | None = None + + +@dataclass +class _TypeDefinition: + """The Type registry's record of a Type: its name, its entries by + relative parameter path, and its Nested Types as a mapping from the + submodule name that requires them to the nested Type's name.""" + + name: str + parameters: Dict[str, _TypeEntry] = field(default_factory=dict) + nested: Dict[str, str] = field(default_factory=dict) + + class ParameterManager(Broadcaster, ParameterGroup): """ A virtual instrument that acts as a manager for a collection of @@ -384,7 +408,7 @@ class ParameterManager(Broadcaster, ParameterGroup): Allows extra-easy on-the-fly addition/removal of new parameters. The Parameter Manager is the root of the parameter tree. It extends the - Parameter Group with file, profile, Lock, and (later) Type logic; + Parameter Group with file, profile, Lock, and Type logic; its submodules are plain Parameter Groups. It implements the Broadcaster contract, so the Server can register @@ -405,6 +429,11 @@ class ParameterManager(Broadcaster, ParameterGroup): def __init__(self, name: str) -> None: super().__init__(name) + # The Type registry: maps each Type name to its ``_TypeDefinition``. + # It lives on the root Parameter Manager only (D15); Parameter + # Groups hold no Types. + self._types: Dict[str, _TypeDefinition] = {} + self._workingDirectory = Path(os.getcwd()) #: default location and name of the parameters save file. @@ -751,6 +780,182 @@ def followers_of(self, name: str) -> "List[str]": followers.append(rel_path) return followers + # ------------------------------------------------------------------ + # Type API (plan decisions D11, D15, D16) + # + # The Type registry (``self._types``) lives on the root Parameter + # Manager only: it maps each Type name to its ``_TypeDefinition``. A + # Type's entries are relative dotted paths with a default value and a + # unit; its Nested Types map the submodule name that requires them to + # the nested Type's name. Type names are refused for the reserved + # Globals submodule ``_globals``. Methods validate before mutating and + # name every offending path or Type in an error. No Type method emits + # a Broadcast yet: the ``pm-type-update`` emissions arrive with the + # Type-editing broadcasts task. + # ------------------------------------------------------------------ + + def _require_type(self, name: str) -> "_TypeDefinition": + """The registry entry of the Type ``name``; raises ``ValueError`` + naming the name when no such Type exists.""" + try: + return self._types[name] + except KeyError: + raise ValueError(f"no Type named '{name}' exists") from None + + def add_type(self, name: str) -> None: + """Create an empty Type named ``name`` in the Type registry. + + Raises ``ValueError`` naming the name when it is the reserved + Globals name ``_globals`` or when a Type with that name exists + already; nothing is changed then. A fresh Type has no entries and + no Nested Types, so it has no Instances until entries are added. + + :param name: Name of the Type. + """ + # validate-then-mutate: both refusals are checked before the + # registry is touched + if name == "_globals": + raise ValueError( + f"'{name}' is not a valid Type name: " + "the Globals submodule name is reserved" + ) + if name in self._types: + raise ValueError(f"a Type named '{name}' already exists") + self._types[name] = _TypeDefinition(name=name) + + def remove_type(self, name: str) -> None: + """Remove the Type ``name`` from the Type registry. + + The parameters of Instances are untouched (D13). Raises + ``ValueError`` when no such Type exists, and — naming every Type + that nests it — while any other Type still requires ``name`` as a + Nested Type; nothing is removed then. + + :param name: Name of the Type. + """ + self._require_type(name) + nesting = sorted( + definition.name + for definition in self._types.values() + if name in definition.nested.values() + ) + if nesting: + nesters = ", ".join(f"'{other}'" for other in nesting) + raise ValueError( + f"cannot remove Type '{name}': nested in Type(s) {nesters}" + ) + del self._types[name] + + def list_types(self) -> List[str]: + """Names of every Type in the Type registry.""" + return list(self._types) + + def get_type(self, name: str) -> "PMTypeBluePrint": + """The Type ``name`` as a :class:`PMTypeBluePrint`: its own + entries as ``{path: {default, unit, target}}``, its Nested Types + as ``{submodule: type}``, and the computed effective parameter + set as ``{path: {unit, from_type}}``. Raises ``ValueError`` + naming the name when no such Type exists, and like + :meth:`_effective_parameters` when its Nested Types cycle or its + effective set contains a path twice.""" + definition = self._require_type(name) + return PMTypeBluePrint( + name=definition.name, + parameters={ + path: { + "default": entry.default, + "unit": entry.unit, + "target": entry.target, + } + for path, entry in definition.parameters.items() + }, + nested=dict(definition.nested), + effective=self._effective_parameters(name), + ) + + def _effective_parameters(self, type_name: str) -> Dict[str, Dict[str, str]]: + """The effective parameter set of the Type ``type_name`` (D11): + every entry path of the Type itself and of its Nested Types, + expanded recursively under the submodule name that requires them, + mapped to ``{"unit": , "from_type": }``. + + Raises ``ValueError`` naming the cycle when the Nested Types + reachable from ``type_name`` form a cycle, naming both Type names + when a Nested Type is missing from the registry, and — naming + every offending path — when an entry path appears twice in the + expanded set.""" + definition = self._require_type(type_name) + # cycles first: the expansion below would not terminate + cycle = self._nested_cycle(definition) + if cycle is not None: + raise ValueError(f"cycle in nested Types: {' -> '.join(cycle)}") + # the cycle walk visited every Nested Type of the closure, so all + # lookups below are known to exist + effective: Dict[str, Dict[str, str]] = {} + duplicated: List[str] = [] + self._collect_effective(definition, "", effective, duplicated) + if duplicated: + paths = ", ".join(f"'{path}'" for path in sorted(duplicated)) + raise ValueError( + f"parameter path(s) {paths} appear more than once in the " + f"effective set of Type '{type_name}'" + ) + return effective + + def _nested_cycle( + self, definition: "_TypeDefinition" + ) -> "List[str] | None": + """The chain of Type names of the first cycle among the Nested + Types reachable from ``definition`` (the chain starts and ends + with the same Type), or ``None`` when none is reachable. Raises + ``ValueError`` naming both names when a Nested Type is not in the + registry. The walk follows each branch with its own chain, so + nesting the same Type at several submodules is not a cycle.""" + def walk(defn: _TypeDefinition, chain: List[str]) -> List[str] | None: + for nested_name in defn.nested.values(): + if nested_name in chain: + return chain[chain.index(nested_name):] + [nested_name] + nested = self._types.get(nested_name) + if nested is None: + raise ValueError( + f"Type '{defn.name}' nests '{nested_name}', " + "which does not exist" + ) + cycle = walk(nested, chain + [nested_name]) + if cycle is not None: + return cycle + return None + + return walk(definition, [definition.name]) + + def _collect_effective( + self, + definition: "_TypeDefinition", + prefix: str, + effective: Dict[str, Dict[str, str]], + duplicated: List[str], + ) -> None: + """Add every entry of ``definition`` — and, recursively, of its + Nested Types under their submodule names — to ``effective``, + recording every path that appears more than once in + ``duplicated`` instead of raising, so one error can name them all.""" + for path, entry in definition.parameters.items(): + full_path = f"{prefix}{path}" + if full_path in effective: + duplicated.append(full_path) + else: + effective[full_path] = { + "unit": entry.unit, + "from_type": definition.name, + } + for submodule, nested_name in definition.nested.items(): + self._collect_effective( + self._types[nested_name], + f"{prefix}{submodule}.", + effective, + duplicated, + ) + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py new file mode 100644 index 0000000..ee343d3 --- /dev/null +++ b/test/pytest/test_pm_types.py @@ -0,0 +1,336 @@ +"""Tests for the Type registry and definitions (plan task 2.1). + +The public ``add_type`` / ``remove_type`` / ``list_types`` / ``get_type`` +methods are exercised directly. The editing methods for entries and Nested +Types arrive with plan task 2.3, so the tests that need Types with entries +or nesting insert their ``_TypeDefinition`` registry records by hand. +Covered: creating, listing, getting and removing Types (with the reserved +Globals name and duplicate-name refusals), the effective parameter set +expansion of Nested Types (cycle refusal, collision refusal), and the +content of the ``PMTypeBluePrint`` ``get_type`` returns, including its +round-trip through the blueprint serialization. +""" + +import re + +import pytest + +from instrumentserver.blueprints import PMTypeBluePrint, deserialize_obj +from instrumentserver.params import ParameterManager, _TypeDefinition, _TypeEntry + + +@pytest.fixture +def pm(tmp_path, monkeypatch): + """A fresh Parameter Manager in an empty working directory.""" + monkeypatch.chdir(tmp_path) + return ParameterManager(name="parameter_manager") + + +def put_type(pm, name, parameters=None, nested=None): + """Insert a ``_TypeDefinition`` into the registry directly: the public + editing API for entries and Nested Types arrives with plan task 2.3.""" + pm._types[name] = _TypeDefinition( + name=name, + parameters={ + path: _TypeEntry(**entry) for path, entry in (parameters or {}).items() + }, + nested=dict(nested or {}), + ) + + +def put_three_tier_registry(pm): + """The mock's three-tier case: ``qubit`` nests ``readout`` at its + submodule ``readout``, which nests ``pulse_window`` at its submodule + ``pw``.""" + put_type( + pm, + "qubit", + parameters={ + "IF": {"default": None, "unit": "Hz"}, + "octave_gain": {"default": 10, "unit": "dB"}, + }, + nested={"readout": "readout"}, + ) + put_type( + pm, + "readout", + parameters={ + "IF": {"default": None, "unit": "Hz"}, + "window": {"default": None, "unit": "s"}, + }, + nested={"pw": "pulse_window"}, + ) + put_type( + pm, + "pulse_window", + parameters={"duration": {"default": None, "unit": "s"}}, + ) + + +# --------------------------------------------------------------------------- +# Definitions: add_type, list_types, remove_type +# --------------------------------------------------------------------------- + + +def test_add_type_creates_an_empty_type(pm): + pm.add_type("qubit") + + assert pm.list_types() == ["qubit"] + bp = pm.get_type("qubit") + assert bp.name == "qubit" + assert bp.parameters == {} + assert bp.nested == {} + # an empty Type has no effective paths and no Instances + assert bp.effective == {} + + +def test_add_type_refuses_the_reserved_globals_name(pm): + with pytest.raises(ValueError, match="'_globals' is not a valid Type name"): + pm.add_type("_globals") + + assert pm.list_types() == [] + + +def test_add_type_refuses_a_duplicate_name_and_changes_nothing(pm): + pm.add_type("qubit") + + with pytest.raises(ValueError, match="a Type named 'qubit' already exists"): + pm.add_type("qubit") + + # the refused call left the original Type untouched + assert pm.list_types() == ["qubit"] + assert pm.get_type("qubit").parameters == {} + + +def test_list_types_lists_every_type(pm): + assert pm.list_types() == [] + + pm.add_type("qubit") + pm.add_type("readout") + + assert sorted(pm.list_types()) == ["qubit", "readout"] + + +def test_remove_type_removes_the_type(pm): + pm.add_type("qubit") + pm.add_type("readout") + + pm.remove_type("qubit") + + assert pm.list_types() == ["readout"] + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.get_type("qubit") + + +def test_remove_type_with_an_unknown_name_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.remove_type("qubit") + + assert pm.list_types() == [] + + +def test_remove_type_refused_while_nested_in_another_type(pm): + put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) + put_type(pm, "qubit", nested={"readout": "readout"}) + + with pytest.raises( + ValueError, + match=re.escape("cannot remove Type 'readout': nested in Type(s) 'qubit'"), + ): + pm.remove_type("readout") + + # the refused removal left both Types in the registry, untouched + assert sorted(pm.list_types()) == ["qubit", "readout"] + assert pm.get_type("readout").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_remove_type_names_every_type_nesting_it(pm): + put_type(pm, "readout") + put_type(pm, "qubit", nested={"readout": "readout"}) + put_type(pm, "qubit2", nested={"readout": "readout"}) + + with pytest.raises(ValueError) as excinfo: + pm.remove_type("readout") + + # every offending Type, not the first (rule 3) + assert str(excinfo.value) == ( + "cannot remove Type 'readout': nested in Type(s) 'qubit', 'qubit2'" + ) + assert sorted(pm.list_types()) == ["qubit", "qubit2", "readout"] + + +def test_get_type_with_an_unknown_name_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.get_type("qubit") + + +# --------------------------------------------------------------------------- +# Effective set: _effective_parameters +# --------------------------------------------------------------------------- + + +def test_effective_set_expands_nested_types_recursively(pm): + put_three_tier_registry(pm) + + assert pm._effective_parameters("qubit") == { + "IF": {"unit": "Hz", "from_type": "qubit"}, + "octave_gain": {"unit": "dB", "from_type": "qubit"}, + "readout.IF": {"unit": "Hz", "from_type": "readout"}, + "readout.window": {"unit": "s", "from_type": "readout"}, + "readout.pw.duration": {"unit": "s", "from_type": "pulse_window"}, + } + + +def test_effective_set_of_the_middle_tier(pm): + put_three_tier_registry(pm) + + assert pm._effective_parameters("readout") == { + "IF": {"unit": "Hz", "from_type": "readout"}, + "window": {"unit": "s", "from_type": "readout"}, + "pw.duration": {"unit": "s", "from_type": "pulse_window"}, + } + + +def test_the_same_type_nested_twice_is_not_a_cycle(pm): + put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) + put_type(pm, "qubit", nested={"ro1": "readout", "ro2": "readout"}) + + assert pm._effective_parameters("qubit") == { + "ro1.IF": {"unit": "Hz", "from_type": "readout"}, + "ro2.IF": {"unit": "Hz", "from_type": "readout"}, + } + + +def test_effective_set_refuses_a_cycle(pm): + put_type(pm, "qubit", nested={"readout": "readout"}) + put_type(pm, "readout", nested={"qubit": "qubit"}) + + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: qubit -> readout -> qubit"), + ): + pm._effective_parameters("qubit") + + # get_type expands the effective set too, so it refuses the cycle as + # well, walking from its own Type + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: readout -> qubit -> readout"), + ): + pm.get_type("readout") + + +def test_effective_set_refuses_a_type_nested_in_itself(pm): + put_type(pm, "loop", nested={"self": "loop"}) + + with pytest.raises( + ValueError, match=re.escape("cycle in nested Types: loop -> loop") + ): + pm._effective_parameters("loop") + + +def test_effective_set_refuses_a_nested_type_missing_from_the_registry(pm): + put_type(pm, "qubit", nested={"readout": "readout"}) + + with pytest.raises( + ValueError, + match=re.escape("Type 'qubit' nests 'readout', which does not exist"), + ): + pm._effective_parameters("qubit") + + +def test_effective_set_refuses_a_duplicated_path_and_names_it(pm): + # the Type's own entry "readout.IF" collides with the entry "IF" of + # the Nested Type required at the submodule "readout" + put_type( + pm, + "qubit", + parameters={"readout.IF": {"default": None, "unit": "Hz"}}, + nested={"readout": "readout"}, + ) + put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path(s) 'readout.IF' appear more than once in the " + "effective set of Type 'qubit'" + ), + ): + pm._effective_parameters("qubit") + + +def test_effective_set_names_every_duplicated_path(pm): + put_type( + pm, + "qubit", + parameters={ + "readout.IF": {"default": None, "unit": "Hz"}, + "readout.window": {"default": None, "unit": "s"}, + }, + nested={"readout": "readout"}, + ) + put_type( + pm, + "readout", + parameters={ + "IF": {"default": None, "unit": "Hz"}, + "window": {"default": None, "unit": "s"}, + }, + ) + + with pytest.raises(ValueError) as excinfo: + pm._effective_parameters("qubit") + + # every offending path, not the first (rule 3) + message = str(excinfo.value) + assert "parameter path(s) 'readout.IF', 'readout.window' appear more than once" in message + assert message.endswith("effective set of Type 'qubit'") + + +# --------------------------------------------------------------------------- +# Blueprint content: get_type and PMTypeBluePrint +# --------------------------------------------------------------------------- + + +def test_get_type_returns_the_full_blueprint(pm): + put_three_tier_registry(pm) + + bp = pm.get_type("qubit") + + assert isinstance(bp, PMTypeBluePrint) + assert bp.name == "qubit" + assert bp.parameters == { + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + } + assert bp.nested == {"readout": "readout"} + assert bp.effective == { + "IF": {"unit": "Hz", "from_type": "qubit"}, + "octave_gain": {"unit": "dB", "from_type": "qubit"}, + "readout.IF": {"unit": "Hz", "from_type": "readout"}, + "readout.window": {"unit": "s", "from_type": "readout"}, + "readout.pw.duration": {"unit": "s", "from_type": "pulse_window"}, + } + + +def test_pm_type_blueprint_round_trips_through_serialization(): + bp = PMTypeBluePrint( + name="qubit", + parameters={ + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + }, + nested={"readout": "readout"}, + effective={ + "IF": {"unit": "Hz", "from_type": "qubit"}, + "octave_gain": {"unit": "dB", "from_type": "qubit"}, + }, + ) + + round_tripped = deserialize_obj(bp.toJson()) + + assert isinstance(round_tripped, PMTypeBluePrint) + assert round_tripped == bp From 3c275d2ccdb9b50ec6eace0374a8e5a298022915 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 16:00:45 -0500 Subject: [PATCH 035/107] 2.1: fix from review round 1: root-only registry test, nesting-Type removal test, wire-format audit row --- TEST_AUDIT.md | 1 + test/pytest/test_pm_types.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 05ce0eb..9e2388f 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -36,6 +36,7 @@ States: | gui_features.md (future) | GUI model population | `InstrumentModelBase.addItem` (`gui/base_instrument.py:300`) drives all three GUI population flows (`__init__`, `refreshAll`, `updateParameter` → `insertItemTo` / star / trash handling) per GitNexus; impact analysis with `includeTests` finds no test at any depth | Found during Phase 1 change-detection review (no docs script yet; behavior not yet documented) | gap | `test_server_gui.py` / `test_client_station.py` exercise the path only indirectly, nothing asserts addItem's hierarchy building; natural candidate for a direct model test when gui_features.md is verified | | client.md | Parameter snapshots | Relative Client-side paths, selected and all-instrument save/restore, flat and nested shapes, and nested `setParameters` rejection match the guide. Native JSON booleans still become `0.0`/`1.0` and fail QCoDeS Boolean validation | `section_save_and_restore_parameter_values` in `verify_client.py` | covered | `test_parameter_snapshot_files_and_current_boolean_limitation` covers the end-to-end workflow and explicitly references open product bug #152. The issue remains open and is not fixed in this documentation pass | | client.md | Errors and timeouts | Server validation failures arrive as generic `Exception` objects. A timeout discards the old socket, connects a replacement without retrying, lets the original Server call finish exactly once, and permits later requests. `raise_exceptions=False` logs and returns `None`; `disconnect()` is permanent | `section_handle_errors_and_timeouts` in `verify_client.py` | covered | `test_server_errors_timeout_socket_replacement_and_quiet_mode` and `test_timeout_dummy_responds_to_idn` | +| user_guide/parameter_manager.md (future) | Types — `get_type` through a proxy | `bluePrintToDict` stringifies scalar leaves and `deserialize_obj` re-parses them numerically, so a Type entry `default` such as the string `"10"` comes back as the int `10` over the wire | Found during the plan 2.1 review; reproduced by round-tripping a `PMTypeBluePrint` through `bluePrintToDict`/`deserialize_obj` | gap | Pre-existing wire-format limitation shared with every blueprint payload (e.g. `PMLockBluePrint`, `ParameterBroadcastBluePrint.value`); not introduced by 2.1; noted per plan rule 6, not fixed here | ## Manual checks diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index ee343d3..45b12a4 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -91,6 +91,15 @@ def test_add_type_refuses_the_reserved_globals_name(pm): assert pm.list_types() == [] +def test_the_type_registry_lives_on_the_root_only(pm): + pm.add_parameter("q01.IF") + + assert hasattr(pm, "_types") + # the Parameter Group q01 carries no registry and no Type methods (D15) + assert not hasattr(pm.q01, "_types") + assert not hasattr(pm.q01, "add_type") + + def test_add_type_refuses_a_duplicate_name_and_changes_nothing(pm): pm.add_type("qubit") @@ -161,6 +170,20 @@ def test_remove_type_names_every_type_nesting_it(pm): assert sorted(pm.list_types()) == ["qubit", "qubit2", "readout"] +def test_remove_type_removes_a_type_that_nests_other_types(pm): + # qubit nests readout but is nested by nobody: removing it is allowed, + # and the Nested Type readout stays in the registry untouched + put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) + put_type(pm, "qubit", nested={"readout": "readout"}) + + pm.remove_type("qubit") + + assert pm.list_types() == ["readout"] + assert pm.get_type("readout").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + def test_get_type_with_an_unknown_name_raises_naming_it(pm): with pytest.raises(ValueError, match="no Type named 'qubit' exists"): pm.get_type("qubit") From 9e73866aa1a31b8298262dd8768182212e9df11e Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 16:09:25 -0500 Subject: [PATCH 036/107] 2.1: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 25 +++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 8d0c3dd..7f801d0 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -238,3 +238,28 @@ Every `ParameterManager` Lock method that changes a Lock now emits one `pm-lock- - The coder tried to write a scratch file to opencode's temp dir outside the repo. It was rejected, and the coder used `orchestration/1.3/` and deleted the file afterwards. - plan-checker-glm sent worker_done twice in round 0. Orca rejected the second one. - No stalls or nudges were needed in any round. + +## 2.1 Type registry and definitions — 2026-09-24 + +The root `ParameterManager` now has a Type registry, `self._types`, which maps each Type name to a `_TypeDefinition(name, parameters, nested)`. Entries are `_TypeEntry(default, unit, target)`, and Parameter Groups hold no Types (D15). The Type API is `add_type` (refuses `_globals` and duplicates), `remove_type` (refuses while any Type nests it, naming every nester), `list_types` (returns `List[str]`) and `get_type`. `get_type` returns the new `PMTypeBluePrint(name, parameters, nested, effective)` from `blueprints.py`, which is also in the `BluePrintType` union. `_effective_parameters` builds the effective set as `{path: {unit, from_type}}`. `_nested_cycle` runs first and refuses a cycle, naming the chain; it refuses a missing Nested Type too. `_collect_effective` then expands the Nested Types under their submodule names and collects every duplicated path into a single error. No Type method emits a Broadcast yet (that comes in 2.5). The new `test/pytest/test_pm_types.py` has 21 server-free unit tests. + +### Commit by commit +- `83afee7` The dataclasses, the registry, the Type API, the effective-set helpers and 19 tests. `add_nested_type` belongs to 2.3, so the orchestrator's scope note said to raise on cycles from `_effective_parameters` and to build Nested Types in tests by putting `_TypeDefinition`s straight into `pm._types` (the `put_type` helper). The tests cover the definitions, three-tier expansion from the mock and its middle tier, and `test_the_same_type_nested_twice_is_not_a_cycle`. They also cover the refusals for a two-Type cycle, a self-nest, a missing Nested Type and one or two duplicated paths, plus full blueprint equality and a `toJson` → `deserialize_obj` round-trip. The coder found that the public methods need quoted return annotations: with `PMTypeBluePrint` unquoted, proxy method generation broke 8 tests in its own run. This is the 0.2 rule again. Orchestrator run: ruff clean, 19 passed in `test_pm_types.py`, 257 in the full suite. +- `3c275d2` Fix from round 0, three items: + - `test_the_type_registry_lives_on_the_root_only`: after `pm.add_parameter("q01.IF")`, `pm.q01` has no `_types` and no `add_type`. Both test reviewers caught it (should-fix): no test touched a Parameter Group, so moving the registry there would have failed nothing. + - `test_remove_type_removes_a_type_that_nests_other_types`: removing `qubit`, which nests `readout`, succeeds and leaves `readout` untouched. test-reviewer-glm caught it (should-fix), and the orchestrator confirmed that only the refusal direction was tested. + - A `gap` row in `TEST_AUDIT.md`: `bluePrintToDict` stringifies scalar leaves and `deserialize_obj` parses them back as numbers, so a string `default` of `"10"` comes back through a proxy `get_type` as the int `10`. This was already true before 2.1 and affects every blueprint payload. reviewer-glm (nit) and reviewer-qwen (observation) raised it, and it went to the audit under plan rule 6 with no code change. + All six reviewers approved in re-review, and both test reviewers confirmed their items fixed. Orchestrator run: ruff clean, 21 in `test_pm_types.py`, 259 in the full suite. + +### Dropped findings +- `remove_type` counts a Type that nests itself as its own nester, so it can never be removed. The task says "any *other* Type" (reviewer-glm, test-reviewer-qwen, plan-checker-glm, plan-checker-qwen, all nits) → not sent. The state can only be built by hand until 2.3's `add_nested_type`, which refuses cycles. The orchestrator flagged it for the 2.3 spec to decide. +- `_collect_effective` lists a path that appears three or more times twice in the error message (plan-checker-glm, nit) → not sent, cosmetic. + +### Loose ends +- For 2.3: settle the self-nesting `remove_type` case above and pin it in a test. `add_nested_type` must also do the cycle check the plan asks for; for now only `_effective_parameters` checks. +- `get_type` returns fresh dicts, so the blueprint cannot alias the registry, but only by construction: no test checks it (test-reviewer-qwen, observation). + +### Process notes +- The orchestrator's watcher missed the six reviewers' ruff permission prompts for about 10 minutes in round 0. A prompt sweep was added to each wait cycle. +- Piping `uv run pytest | tail` from the orchestrator's session hung. Running the suite detached, with output to a log file, works. +- reviewer-qwen ran its full-suite runs in the background, logging to `orchestration/2.1/`, and deleted its logs afterwards in both rounds. There were no rejected permissions and no stalls. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 9480cde..4105102 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -469,7 +469,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 2 — Types -- [ ] **2.1 Type registry and definitions.** In `params.py`: internal dataclasses +- [x] **2.1 Type registry and definitions.** In `params.py`: internal dataclasses `_TypeEntry(default, unit, target)` and `_TypeDefinition(name, parameters: dict[str, _TypeEntry], nested: dict[str, str])`; registry `self._types` on the root only. `add_type`, `remove_type` (raises if any other Type nests it), `list_types`, `get_type` From 6d1560b2eaa4e0b7bbe9e9ddb773afc28b943657 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 16:17:53 -0500 Subject: [PATCH 037/107] =?UTF-8?q?2.2:=20Instance=20matching=20=E2=80=94?= =?UTF-8?q?=20instances=5Fof(type)=20and=20types=5Fof(path)=20per=20D12/D1?= =?UTF-8?q?6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/instrumentserver/params.py | 125 +++++++++++++++++ test/pytest/test_pm_types.py | 248 ++++++++++++++++++++++++++++++++- 2 files changed, 369 insertions(+), 4 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 4a7fa7a..540ae30 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -956,6 +956,131 @@ def _collect_effective( duplicated, ) + # ------------------------------------------------------------------ + # Instance matching (plan decisions D12, D16; ADR-0001) + # + # Instances are duck-typed: a Parameter Group is an Instance of a Type + # because it carries every path of the Type's effective parameter set, + # each with the unit the Type declares; nothing stores membership and + # matching walks the tree on every query. The root is never an + # Instance, and the reserved Globals submodule ``_globals`` — wherever + # it appears in the tree — and everything under it are excluded from + # matching. ``instances_of`` and ``types_of`` are read-only queries: + # they change no state and emit no Broadcast. + # ------------------------------------------------------------------ + + def _iter_submodule_groups(self) -> Iterator[Tuple[str, "ParameterGroup"]]: + """Yield ``(dotted path, Parameter Group)`` for every Parameter + Group below this Parameter Manager, at any depth: never the root + itself, and never the reserved Globals submodule ``_globals`` or + anything inside it (D12).""" + def walk( + group: "ParameterGroup", prefix: str + ) -> Iterator[Tuple[str, "ParameterGroup"]]: + for name, sm in group.submodules.items(): + assert isinstance(sm, ParameterGroup) + if name == "_globals": + continue + path = f"{prefix}{name}" + yield path, sm + yield from walk(sm, f"{path}.") + + yield from walk(self, "") + + @staticmethod + def _carries_effective_set( + group: "ParameterGroup", effective: Dict[str, Dict[str, str]] + ) -> bool: + """Whether the Parameter Group ``group`` carries every path of the + effective set ``effective`` with the unit the Type declares for it + (D12): a match requires existence **and** unit; values are + irrelevant.""" + for path, spec in effective.items(): + try: + param = group._get_param(path) + except ValueError: + return False + if getattr(param, "unit", None) != spec["unit"]: + return False + return True + + def _instances_of_effective( + self, effective: Dict[str, Dict[str, str]] + ) -> List[str]: + """Paths (relative to this Parameter Manager) of every Parameter + Group in the tree that is an Instance for the effective set + ``effective``: every submodule at any depth that carries the whole + set with the declared units (D12).""" + return [ + path + for path, group in self._iter_submodule_groups() + if self._carries_effective_set(group, effective) + ] + + def instances_of(self, type_name: str) -> List[str]: + """Paths (relative to this Parameter Manager) of every Instance of + the Type ``type_name`` (D12): every Parameter Group at any depth + that carries every path of the Type's effective set with the unit + the Type declares; values are irrelevant. The root is never an + Instance, the Globals submodule ``_globals`` and everything under + it are excluded, and an empty Type has no Instances (ADR-0001). + Matching is computed on demand; this query changes no state. + + Raises ``ValueError`` naming the name when no such Type exists, + and like :meth:`_effective_parameters` when its Nested Types cycle + or its effective set contains a path twice. + + :param type_name: Name of the Type. + :return: Paths of the Instances, in tree order. + """ + effective = self._effective_parameters(type_name) + if not effective: + return [] + return self._instances_of_effective(effective) + + def types_of(self, path: str) -> List[str]: + """Names of the Types claiming the parameter at ``path`` (a dotted + path relative to this Parameter Manager), innermost first (D16): + the Type whose Instance is the deepest submodule above the + parameter wins, then the one with the largest effective set, with + any remaining tie broken by Type name. A Type claims the parameter + when an Instance of it above the parameter — some submodule the + parameter lives under — carries the parameter's path relative to + that Instance in its effective set. Parameters under the Globals + submodule ``_globals`` are claimed by nothing, since matching + excludes ``_globals`` (D12). Matching is computed on demand; this + query changes no state. + + Raises ``ValueError`` naming the path when no parameter exists + there. + + :param path: Path of the parameter. + :return: Claiming Type names, innermost first. + """ + self._resolve_param(path) + # one claim per Type: when a Type claims the parameter through + # more than one Instance, its innermost Instance orders it + claims: Dict[str, Tuple[int, int]] = {} + for definition in self._types.values(): + effective = self._effective_parameters(definition.name) + if not effective: + continue + for submodule_path in self._instances_of_effective(effective): + prefix = f"{submodule_path}." + if not path.startswith(prefix): + continue + if path[len(prefix):] not in effective: + continue + depth = len(submodule_path) + size = len(effective) + known = claims.get(definition.name) + if known is None or depth > known[0]: + claims[definition.name] = (depth, size) + return sorted( + claims, + key=lambda name: (-claims[name][0], -claims[name][1], name), + ) + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 45b12a4..cf471b6 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -1,4 +1,5 @@ -"""Tests for the Type registry and definitions (plan task 2.1). +"""Tests for the Type registry and definitions (plan task 2.1) and for +the duck-typed Instance matching (plan task 2.2). The public ``add_type`` / ``remove_type`` / ``list_types`` / ``get_type`` methods are exercised directly. The editing methods for entries and Nested @@ -6,9 +7,11 @@ or nesting insert their ``_TypeDefinition`` registry records by hand. Covered: creating, listing, getting and removing Types (with the reserved Globals name and duplicate-name refusals), the effective parameter set -expansion of Nested Types (cycle refusal, collision refusal), and the -content of the ``PMTypeBluePrint`` ``get_type`` returns, including its -round-trip through the blueprint serialization. +expansion of Nested Types (cycle refusal, collision refusal), the content +of the ``PMTypeBluePrint`` ``get_type`` returns, its round-trip through +the blueprint serialization, and the Instance matching queries +``instances_of`` and ``types_of`` (existence and unit, any depth, never +the root, never the Globals submodule, ordering of the claiming Types). """ import re @@ -357,3 +360,240 @@ def test_pm_type_blueprint_round_trips_through_serialization(): assert isinstance(round_tripped, PMTypeBluePrint) assert round_tripped == bp + + +# --------------------------------------------------------------------------- +# Instance matching: instances_of and types_of (plan task 2.2) +# --------------------------------------------------------------------------- + + +def put_three_tier_tree(pm): + """The parameter tree matching the three-tier registry: ``q01`` is an + Instance of ``qubit``, ``q01.readout`` of ``readout``, and + ``q01.readout.pw`` of ``pulse_window``. Every parameter carries a + value of its own, which matching must ignore.""" + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.readout.window", initial_value=2e-6, unit="s") + pm.add_parameter("q01.readout.pw.duration", initial_value=500e-9, unit="s") + + +def put_globals_parameter(pm, path, unit=""): + """Create a parameter under the reserved Globals submodule the way the + internal default-Target helper will (the public ``add_parameter`` + refuses Globals from plan task 3.1 on).""" + parent = pm._get_parent(path, create_parent=True) + parent._add_own_parameter(path.split(".")[-1], unit=unit) + + +def test_instances_of_finds_the_three_tier_instances(pm): + put_three_tier_registry(pm) + put_three_tier_tree(pm) + + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("pulse_window") == ["q01.readout.pw"] + + +def test_types_of_orders_innermost_first(pm): + put_three_tier_registry(pm) + put_three_tier_tree(pm) + + # the deepest Instance claims first: pulse_window (1 effective path) + # before readout (3) before qubit (5) + assert pm.types_of("q01.readout.pw.duration") == [ + "pulse_window", + "readout", + "qubit", + ] + assert pm.types_of("q01.readout.IF") == ["readout", "qubit"] + assert pm.types_of("q01.IF") == ["qubit"] + assert pm.types_of("q01.octave_gain") == ["qubit"] + + +def test_q01_readout_is_an_instance_of_readout_on_its_own(pm): + # the parent carries only the readout shape, not the full qubit shape: + # the nested Parameter Group matches readout regardless of its parent + put_three_tier_registry(pm) + pm.add_parameter("q02.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q02.readout.window", initial_value=2e-6, unit="s") + pm.add_parameter("q02.readout.pw.duration", initial_value=500e-9, unit="s") + + assert pm.instances_of("readout") == ["q02.readout"] + assert pm.instances_of("qubit") == [] + assert pm.types_of("q02.readout.IF") == ["readout"] + + +def test_a_unit_mismatch_excludes_the_submodule(pm): + # every effective path exists, but octave_gain carries the wrong unit: + # a match requires existence and unit (D12), so the whole submodule is + # no Instance + put_type( + pm, + "qubit", + parameters={ + "IF": {"default": None, "unit": "Hz"}, + "octave_gain": {"default": 10, "unit": "dB"}, + }, + ) + pm.add_parameter("q01.IF", unit="Hz") + pm.add_parameter("q01.octave_gain", unit="Hz") + + assert pm.instances_of("qubit") == [] + assert pm.types_of("q01.IF") == [] + + +def test_a_unit_mismatch_at_one_level_leaves_the_deeper_instances(pm): + # q03.readout.window carries the wrong unit: neither q03 (for qubit) + # nor q03.readout (for readout) matches, but q03.readout.pw still + # matches pulse_window on its own + put_three_tier_registry(pm) + pm.add_parameter("q03.IF", unit="Hz") + pm.add_parameter("q03.octave_gain", unit="dB") + pm.add_parameter("q03.readout.IF", unit="Hz") + pm.add_parameter("q03.readout.window", unit="Hz") + pm.add_parameter("q03.readout.pw.duration", unit="s") + + assert pm.instances_of("qubit") == [] + assert pm.instances_of("readout") == [] + assert pm.instances_of("pulse_window") == ["q03.readout.pw"] + assert pm.types_of("q03.readout.pw.duration") == ["pulse_window"] + + +def test_extra_parameters_do_not_matter(pm): + put_type(pm, "qubit", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_parameter("q01.IF", unit="Hz") + # extra parameters and extra Parameter Groups change nothing + pm.add_parameter("q01.extra", unit="dB") + pm.add_parameter("q01.sub.extra", unit="s") + + assert pm.instances_of("qubit") == ["q01"] + assert pm.types_of("q01.IF") == ["qubit"] + + +def test_two_types_on_one_submodule(pm): + put_type( + pm, + "readout", + parameters={ + "IF": {"default": None, "unit": "Hz"}, + "window": {"default": None, "unit": "s"}, + }, + ) + put_type(pm, "ro_small", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_parameter("q01.readout.IF", unit="Hz") + pm.add_parameter("q01.readout.window", unit="s") + + # one submodule can be an Instance of several Types at once + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("ro_small") == ["q01.readout"] + # the same Instance claims both, so the larger effective set wins (D16) + assert pm.types_of("q01.readout.IF") == ["readout", "ro_small"] + + +def test_types_of_breaks_a_tie_by_type_name(pm): + # two Types of the same size on the same Instance: the order falls + # back to the Type name (the registry holds ro_b first) + put_type(pm, "ro_b", parameters={"IF": {"default": None, "unit": "Hz"}}) + put_type(pm, "ro_a", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_parameter("q01.readout.IF", unit="Hz") + + assert pm.types_of("q01.readout.IF") == ["ro_a", "ro_b"] + + +def test_an_empty_type_has_no_instances(pm): + pm.add_type("empty") + pm.add_parameter("q01.anything", unit="Hz") + + assert pm.instances_of("empty") == [] + + +def test_the_root_is_never_an_instance(pm): + put_type(pm, "flat", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_parameter("IF", unit="Hz") + pm.add_parameter("under_group.IF", unit="Hz") + + # the root carries the shape but is not a submodule: only under_group + # is an Instance, and the root parameter is claimed by nothing + assert pm.instances_of("flat") == ["under_group"] + assert pm.types_of("IF") == [] + + +def test_nothing_under_the_globals_submodule_matches(pm): + put_type(pm, "qubit", parameters={"IF": {"default": None, "unit": "Hz"}}) + # a Parameter Group directly under Globals and one deeper inside it + # both carry the whole shape with the right unit — and still match + # nothing, because matching excludes the Globals subtree (D12) + put_globals_parameter(pm, "_globals.qubit.IF", unit="Hz") + put_globals_parameter(pm, "_globals.deep.qubit.IF", unit="Hz") + pm.add_parameter("q09.IF", unit="Hz") + + assert pm.instances_of("qubit") == ["q09"] + assert pm.types_of("_globals.qubit.IF") == [] + assert pm.types_of("_globals.deep.qubit.IF") == [] + + +def test_values_are_irrelevant_to_matching(pm): + put_type( + pm, + "qubit", + parameters={ + "IF": {"default": None, "unit": "Hz"}, + "octave_gain": {"default": 10, "unit": "dB"}, + }, + ) + pm.add_parameter("q01.IF", initial_value=1, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=999, unit="dB") + pm.add_parameter("q02.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q02.octave_gain", initial_value=0, unit="dB") + + # the values differ wildly between the two Instances; both match + assert sorted(pm.instances_of("qubit")) == ["q01", "q02"] + + +def test_a_type_nested_at_two_submodules_matches_only_whole_carriers(pm): + put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) + put_type(pm, "qubit", nested={"ro1": "readout", "ro2": "readout"}) + pm.add_parameter("q01.ro1.IF", unit="Hz") + pm.add_parameter("q01.ro2.IF", unit="Hz") + # q02 carries only one of the two nested halves + pm.add_parameter("q02.ro1.IF", unit="Hz") + + # qubit's effective set is ro1.IF and ro2.IF: only q01 carries both + assert pm.instances_of("qubit") == ["q01"] + # each half is an Instance of readout on its own, q02.ro1 included + assert sorted(pm.instances_of("readout")) == [ + "q01.ro1", + "q01.ro2", + "q02.ro1", + ] + assert pm.types_of("q01.ro1.IF") == ["readout", "qubit"] + assert pm.types_of("q02.ro1.IF") == ["readout"] + + +def test_instances_of_with_an_unknown_type_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.instances_of("qubit") + + +def test_types_of_with_an_unknown_path_raises_naming_it(pm): + pm.add_parameter("q01.IF", unit="Hz") + + with pytest.raises(ValueError, match="Parameter 'nope' does not exist"): + pm.types_of("nope") + # a Parameter Group path is not a parameter path + with pytest.raises(ValueError, match="Parameter 'q01' does not exist"): + pm.types_of("q01") + + +def test_types_of_returns_empty_for_an_unclaimed_parameter(pm): + pm.add_parameter("q01.extra", unit="s") + # with an empty registry nothing claims anything + assert pm.types_of("q01.extra") == [] + + put_three_tier_registry(pm) + put_three_tier_tree(pm) + + # the extra parameter is in no Type's effective set + assert pm.types_of("q01.extra") == [] From ef1f2cf6ccf8005cf42e42f0d76f76c02146b99e Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 16:31:20 -0500 Subject: [PATCH 038/107] 2.2: fix from review round 1: test for a Type claiming through two Instances, ordered by its innermost Instance --- test/pytest/test_pm_types.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index cf471b6..606fd88 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -412,6 +412,40 @@ def test_types_of_orders_innermost_first(pm): assert pm.types_of("q01.octave_gain") == ["qubit"] +def test_a_type_claiming_through_two_instances_is_ordered_by_its_innermost_instance(pm): + # one Type claiming a parameter through more than one Instance is + # listed once, ordered by its innermost Instance + put_type( + pm, + "zzz", + parameters={ + "x": {"default": None, "unit": "Hz"}, + "a.x": {"default": None, "unit": "Hz"}, + }, + ) + put_type( + pm, + "aaa", + parameters={ + "a.x": {"default": None, "unit": "Hz"}, + "top": {"default": None, "unit": "s"}, + }, + ) + pm.add_parameter("a.x", unit="Hz") + pm.add_parameter("a.top", unit="s") + pm.add_parameter("a.a.x", unit="Hz") + pm.add_parameter("a.a.a.x", unit="Hz") + + # both a and a.a carry the whole zzz shape (x and a.x with unit Hz) + assert pm.instances_of("zzz") == ["a", "a.a"] + # aaa is carried by a alone (a.a lacks a.top) + assert pm.instances_of("aaa") == ["a"] + # zzz claims a.a.x through a.a (innermost) and through a; aaa claims + # it only through a: the innermost Instance puts zzz first, although + # the Type-name tie-break alone would put aaa first + assert pm.types_of("a.a.x") == ["zzz", "aaa"] + + def test_q01_readout_is_an_instance_of_readout_on_its_own(pm): # the parent carries only the readout shape, not the full qubit shape: # the nested Parameter Group matches readout regardless of its parent From f6f53b167a37a9ddaeb835108918c38122f47f24 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 16:38:37 -0500 Subject: [PATCH 039/107] 2.2: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 36 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 7f801d0..6a604d9 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -263,3 +263,39 @@ The root `ParameterManager` now has a Type registry, `self._types`, which maps e - The orchestrator's watcher missed the six reviewers' ruff permission prompts for about 10 minutes in round 0. A prompt sweep was added to each wait cycle. - Piping `uv run pytest | tail` from the orchestrator's session hung. Running the suite detached, with output to a log file, works. - reviewer-qwen ran its full-suite runs in the background, logging to `orchestration/2.1/`, and deleted its logs afterwards in both rounds. There were no rejected permissions and no stalls. + +## 2.2 Instance matching — 2026-09-24 + +`ParameterManager` in `src/instrumentserver/params.py` now answers the two D12/D16 matching queries. `instances_of(type_name)` returns the paths of every Parameter Group, at any depth, that carries every path of the Type's effective set with the declared unit. Values don't matter, the root is never an Instance, `_globals` and everything under it are skipped, and an empty Type has no Instances. `types_of(path)` returns the Types claiming a parameter, innermost first. A Type claims the parameter when one of its Instances sits above it and the parameter's path relative to that Instance is in the effective set. Matching is duck-typed and walks the tree on every query through the private helpers `_iter_submodule_groups`, `_carries_effective_set` and `_instances_of_effective`. Both queries change no state and emit no Broadcast. `test/pytest/test_pm_types.py` grew from 21 to 38 server-free tests. + +### Commit by commit +- `6d1560b` The two queries, their helpers and 16 tests. The plan left some behaviour open, so the orchestrator wrote its reading into the coder spec: `types_of` takes a parameter path (the mock's `claims()` works per row), a remaining tie after depth and size is broken by Type name, and an unknown Type or path raises `ValueError` naming it. The coder added two readings of its own. `_globals` is skipped by name at any depth, not only at the root, and a Type that claims a parameter through two Instances is listed once, placed by its innermost Instance. Depth is `len(submodule_path)`. The string length ranks depth correctly because every claiming Instance is a dotted prefix of the parameter path. The tests cover the plan's five cases: the three-tier mock (`test_instances_of_finds_the_three_tier_instances`, `test_types_of_orders_innermost_first`), `test_a_unit_mismatch_excludes_the_submodule`, `test_extra_parameters_do_not_matter`, `test_two_types_on_one_submodule` and `test_q01_readout_is_an_instance_of_readout_on_its_own`. The other tests cover these cases: + - a mismatch at one level leaving a deeper Instance + - the Type-name tie-break + - the root + - `_globals` at two depths, with parameters built by the `put_globals_parameter` helper through `_add_own_parameter` + - values being irrelevant + - one Type nested at two submodules + - the error paths, including a Parameter Group path given to `types_of` + - an unclaimed parameter + + Orchestrator run: ruff clean, 37 passed in `test_pm_types.py`, 275 in the full suite. +- `ef1f2cf` Fix from round 0, test only: `test_a_type_claiming_through_two_instances_is_ordered_by_its_innermost_instance`. The test sets up Type `zzz = {x, a.x}` and Type `aaa = {a.x, top}`, with parameters at `a.x`, `a.top`, `a.a.x` and `a.a.a.x`. `zzz` has Instances at `a` and `a.a`, and `types_of("a.a.x")` must return `["zzz", "aaa"]`. If the `depth > known[0]` dedup ever falls back to keeping the first claim it sees, the Type-name tie-break puts `aaa` first. All four general and test reviewers raised this gap (should-fix). The `known is None or depth > known[0]` branch could be broken and every test would still pass. The fix list used test-reviewer-glm's scenario. The coder briefly flipped the branch locally to show the test catches the regression, then reverted it before committing. Two re-reviewers ran the same check in scratch scripts. All six approved in re-review, and all four who raised the item confirmed it fixed. Orchestrator run: ruff clean, 38 in `test_pm_types.py`, 276 in the full suite. + +### Dropped findings +- The unit check is strict. A parameter created without a unit has `unit=None`, so it never matches a Type entry whose unit is `""` (plan-checker-qwen, nit) → not sent. The plan says nothing about this case. It matters for the unit propagation in 2.3 and the unit-conflict scan in 2.4 (see Questions). +- A user-made `q01._globals` group is excluded too, which goes beyond the root-level Globals the glossary defines (plan-checker-qwen, nit). No test covers the nested case (test-reviewer-glm, nit) → not sent. ADR-0001 says `_globals` is "excluded from matching at any depth", so the coder's reading stands, and 3.1 checks the exclusion again. +- The comment doesn't explain why `len(submodule_path)` is a safe measure of depth (reviewer-glm, nit), and the `types_of` docstring doesn't mention the cycle and duplicate errors it inherits from `_effective_parameters` (reviewer-qwen, nit) → not sent. +- Two tests wrap `instances_of` in `sorted()` although the docstring promises tree order (test-reviewer-qwen, nit) → not sent, because the plan fixes no order. The 5.5 instances pane may want the order pinned. +- `put_globals_parameter` creates plain `Parameter`s and its docstring cites a 3.1 refusal that doesn't exist yet (test-reviewer-qwen, nit). No test checks that cycle and duplicate errors pass through `instances_of` (test-reviewer-glm, nit) → not sent. The second state can't be reached once 2.3's `add_nested_type` refuses cycles. + +### Questions to Marcos +- The orchestrator flagged these readings for the run report: `types_of` takes a parameter path; a remaining tie is broken by Type name; unknown names raise; `_globals` is excluded at any depth; a `None` unit doesn't match a declared `""`. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- For 2.3/2.4: decide whether a parameter with no unit counts as carrying `""` (see above). For now `_carries_effective_set` compares `param.unit` to the declared unit exactly. +- `types_of` recomputes every Type's effective set and Instances on each call. That is what ADR-0001 asks for (no cache), but the cost grows with the number of Types times the tree size. +- No proxy tests for `instances_of`/`types_of` yet; they belong to 2.5. + +### Process notes +- Reviewers checked their findings with scratch scripts in `orchestration/2.2/` in both rounds. The orchestrator read each script before allowing it to run, and the reviewers deleted them afterwards. One scratch tempdir left under `round-0/` was removed by the orchestrator. There were no rejected permissions, no stalls and no nudges. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 4105102..9c1461a 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -478,7 +478,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session raises on cycles (checked in `add_nested_type`) and on a path appearing twice. `_globals` refused as a Type name. Tests: `test_pm_types.py` unit part (definitions, effective set, cycle refusal, collision refusal, blueprint content). -- [ ] **2.2 Instance matching.** `instances_of(type)` and `types_of(path)` per D12 (existence +- [x] **2.2 Instance matching.** `instances_of(type)` and `types_of(path)` per D12 (existence **and** unit; every submodule at any depth; never root; never under `_globals`; empty Type → none). `types_of` orders innermost first (longest submodule path), then largest effective set. Tests: `test_pm_types.py` — the three-tier case from the mock (`qubit` nests `readout` From 6724148801f6e421c85b90d3a3010c8ca3ec81df Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 17:01:32 -0500 Subject: [PATCH 040/107] 2.3: Type edits with Instance side effects --- src/instrumentserver/params.py | 463 +++++++++++++++++++- test/pytest/test_pm_types.py | 775 +++++++++++++++++++++++++++++---- 2 files changed, 1126 insertions(+), 112 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 540ae30..38580a7 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -884,6 +884,18 @@ def _effective_parameters(self, type_name: str) -> Dict[str, Dict[str, str]]: when a Nested Type is missing from the registry, and — naming every offending path — when an entry path appears twice in the expanded set.""" + expanded = self._expand_effective(type_name) + return { + path: {"unit": entry.unit, "from_type": from_type} + for path, (entry, from_type) in expanded.items() + } + + def _expand_effective(self, type_name: str) -> Dict[str, Tuple["_TypeEntry", str]]: + """The effective parameter set of the Type ``type_name`` in raw + form: every expanded path mapped to the :class:`_TypeEntry` that + defines it and the name of the Type defining it. Raises the same + errors as :meth:`_effective_parameters` (unknown Type, a cycle, + a Nested Type missing from the registry, a path appearing twice).""" definition = self._require_type(type_name) # cycles first: the expansion below would not terminate cycle = self._nested_cycle(definition) @@ -891,31 +903,50 @@ def _effective_parameters(self, type_name: str) -> Dict[str, Dict[str, str]]: raise ValueError(f"cycle in nested Types: {' -> '.join(cycle)}") # the cycle walk visited every Nested Type of the closure, so all # lookups below are known to exist - effective: Dict[str, Dict[str, str]] = {} + expanded: Dict[str, Tuple[_TypeEntry, str]] = {} duplicated: List[str] = [] - self._collect_effective(definition, "", effective, duplicated) + self._collect_effective(definition, "", expanded, duplicated) if duplicated: paths = ", ".join(f"'{path}'" for path in sorted(duplicated)) raise ValueError( f"parameter path(s) {paths} appear more than once in the " f"effective set of Type '{type_name}'" ) - return effective + return expanded + + def _effective_entries(self, type_name: str) -> Dict[str, _TypeEntry]: + """The effective parameter set of the Type ``type_name`` carrying + the full :class:`_TypeEntry` (default value, unit, Type Lock + Target) of the entry that defines each path: what writing the set + into the tree as parameters needs. Raises like + :meth:`_effective_parameters`.""" + return { + path: entry + for path, (entry, _) in self._expand_effective(type_name).items() + } def _nested_cycle( - self, definition: "_TypeDefinition" + self, + definition: "_TypeDefinition", + types: Dict[str, "_TypeDefinition"] | None = None, ) -> "List[str] | None": """The chain of Type names of the first cycle among the Nested Types reachable from ``definition`` (the chain starts and ends with the same Type), or ``None`` when none is reachable. Raises - ``ValueError`` naming both names when a Nested Type is not in the - registry. The walk follows each branch with its own chain, so - nesting the same Type at several submodules is not a cycle.""" + ``ValueError`` naming both names when a Nested Type is not in + ``types``. The walk follows each branch with its own chain, so + nesting the same Type at several submodules is not a cycle. + ``types`` defaults to the Type registry; ``add_nested_type`` + passes a copied registry holding a candidate definition so it can + refuse a cycle before mutating anything.""" + if types is None: + types = self._types + def walk(defn: _TypeDefinition, chain: List[str]) -> List[str] | None: for nested_name in defn.nested.values(): if nested_name in chain: return chain[chain.index(nested_name):] + [nested_name] - nested = self._types.get(nested_name) + nested = types.get(nested_name) if nested is None: raise ValueError( f"Type '{defn.name}' nests '{nested_name}', " @@ -932,22 +963,20 @@ def _collect_effective( self, definition: "_TypeDefinition", prefix: str, - effective: Dict[str, Dict[str, str]], + effective: Dict[str, Tuple[_TypeEntry, str]], duplicated: List[str], ) -> None: """Add every entry of ``definition`` — and, recursively, of its - Nested Types under their submodule names — to ``effective``, - recording every path that appears more than once in - ``duplicated`` instead of raising, so one error can name them all.""" + Nested Types under their submodule names — to ``effective`` as + ``(entry, defining Type name)`` pairs, recording every path that + appears more than once in ``duplicated`` instead of raising, so + one error can name them all.""" for path, entry in definition.parameters.items(): full_path = f"{prefix}{path}" if full_path in effective: duplicated.append(full_path) else: - effective[full_path] = { - "unit": entry.unit, - "from_type": definition.name, - } + effective[full_path] = (entry, definition.name) for submodule, nested_name in definition.nested.items(): self._collect_effective( self._types[nested_name], @@ -1081,6 +1110,408 @@ def types_of(self, path: str) -> List[str]: key=lambda name: (-claims[name][0], -claims[name][1], name), ) + # ------------------------------------------------------------------ + # Type edits with Instance side effects (plan decisions D11, D13, D16) + # + # Every edit validates all its preconditions first and raises before + # touching anything: on an error the Type registry and the parameter + # tree are exactly as they were. The side effects target the Instances + # that exist before the edit — they are computed while the registry + # still holds the old shape, because after the edit no submodule + # matches until it carries what is new — together with the Instances + # of every Type whose effective parameter set contains the edited + # Type through nesting. The affected parameter paths are collected + # de-duplicated and each missing one is created once, through the + # ordinary ``add_parameter`` path, as a ``ManagedParameter`` with the + # entry's default value and unit. A parameter that already exists at + # a target path is left alone: the submodule it lives in simply stops + # being an Instance when its unit differs (D1). No edit emits a + # Broadcast yet: ``pm-type-update`` and the re-emitted + # ``parameter-creation`` arrive with the Type-editing broadcasts task. + # ------------------------------------------------------------------ + + def _nesting_prefixes(self, type_name: str) -> Dict[str, List[str]]: + """Map every Type whose effective parameter set contains the + entries of ``type_name`` through nesting to the dotted submodule + prefixes under which they sit in that set. ``type_name`` itself + maps to ``[""]``; a Type requiring it directly at ``readout`` maps + to ``["readout."]``, and so on transitively, with one prefix per + nesting chain (a Type nesting it at several submodules maps to + several). The walk follows the ``nested`` maps upwards, from the + nested Types to the Types requiring them, and stops at a Type + already on the current branch, so it terminates even on a + registry that holds a cycle (which the public API refuses).""" + prefixes: Dict[str, List[str]] = {type_name: [""]} + + def walk(name: str, prefix: str, branch: Tuple[str, ...]) -> None: + for parent_name, definition in self._types.items(): + for submodule, nested_name in definition.nested.items(): + if nested_name != name or parent_name in branch: + continue + extended = f"{submodule}.{prefix}" + known = prefixes.setdefault(parent_name, []) + if extended not in known: + known.append(extended) + walk(parent_name, extended, branch + (parent_name,)) + + walk(type_name, "", (type_name,)) + return prefixes + + def _group_at(self, path: str) -> "ParameterGroup": + """The Parameter Group at a dotted path relative to this Parameter + Manager (the root itself for the empty path).""" + group: ParameterGroup = self + for segment in path.split("."): + if segment: + submodule = group.submodules[segment] + assert isinstance(submodule, ParameterGroup) + group = submodule + return group + + def _check_creation_targets( + self, targets: List[Tuple[str, str]] + ) -> None: + """Validate the parameters a Type edit is about to create as side + effects, before anything is mutated. ``targets`` holds + ``(Instance path, relative target path)`` pairs. An intermediate + segment of a target may not be an existing parameter (a parameter + cannot have child parameters) and the final segment may not be an + existing Parameter Group (a Parameter Group cannot become a + parameter); a target whose final segment is an existing parameter + is fine — it is left alone. Raises ``ValueError`` naming every + offending full path.""" + offending: Dict[str, str] = {} + seen: set = set() + for instance_path, relative_target in targets: + full = f"{instance_path}.{relative_target}" + if full in seen: + continue + seen.add(full) + group = self._group_at(instance_path) + segments = relative_target.split(".") + for index, segment in enumerate(segments): + last = index == len(segments) - 1 + if segment in group.parameters: + if not last: + blocked = f"{instance_path}.{'.'.join(segments[:index + 1])}" + offending[full] = ( + f"'{blocked}' is a parameter, and cannot have " + "child parameters" + ) + break + if last: + if segment in group.submodules: + offending[full] = ( + f"'{full}' is already a Parameter Group" + ) + break + submodule = group.submodules.get(segment) + if submodule is None: + # missing Parameter Group: it is created on the way, + # so nothing deeper along this target can clash + break + assert isinstance(submodule, ParameterGroup) + group = submodule + if offending: + details = "; ".join( + f"cannot create parameter '{path}': {reason}" + for path, reason in offending.items() + ) + raise ValueError(details) + + def _require_type_entry(self, type_name: str, path: str) -> _TypeEntry: + """The Type ``type_name``'s own entry at ``path``. Raises + ``ValueError`` naming the path — and the Type that defines it, + when the path only reaches the effective parameter set through a + Nested Type — when it is not an entry of the Type itself.""" + definition = self._require_type(type_name) + entry = definition.parameters.get(path) + if entry is not None: + return entry + expanded = self._expand_effective(type_name) + if path in expanded: + from_type = expanded[path][1] + raise ValueError( + f"parameter path '{path}' is not an entry of Type " + f"'{type_name}' itself: it is only in the effective set " + f"through the entry of Type '{from_type}'" + ) + raise ValueError( + f"parameter path '{path}' is not an entry of Type '{type_name}'" + ) + + def _instances_before_edit(self, affected: Dict[str, List[str]]) -> Dict[str, List[str]]: + """The Instances of every Type in ``affected``, computed while the + registry still holds the shape the edit is about to change (D13): + after the edit no submodule matches until it carries what is new, + so the side effects must key off the Instances found now.""" + return {name: self.instances_of(name) for name in affected} + + def add_type_parameter( + self, type_name: str, path: str, default: Any = None, unit: str = "" + ) -> None: + """Add an entry to the Type ``type_name`` (D11) and create the + parameter at ``path`` — with the entry's default value and unit — + in every Instance of the Type that lacks it, and in every Instance + of a Type whose effective parameter set contains ``type_name`` + through nesting (D13). Parameters that already exist at a target + path are left alone, whatever their unit; the missing Parameter + Groups along a target path are created. + + Raises ``ValueError`` — leaving the registry and the parameter + tree untouched — naming every offending path when no such Type + exists, when ``path`` is empty or has an empty segment, when + ``path`` is already in the Type's effective parameter set (naming + the Type that defines it), or when a target path cannot be + created because an intermediate segment is an existing parameter + or the final segment is an existing Parameter Group. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + :param default: Default value the created parameters start with. + :param unit: Unit of the entry and the created parameters. + """ + # validate-then-mutate: every check below runs before the registry + # or the tree is touched + definition = self._require_type(type_name) + if not path or any(segment == "" for segment in path.split(".")): + raise ValueError( + f"'{path}' is not a valid parameter path for a Type entry" + ) + expanded = self._expand_effective(type_name) + if path in expanded: + from_type = expanded[path][1] + raise ValueError( + f"parameter path '{path}' is already in the effective set " + f"of Type '{type_name}' (defined by Type '{from_type}')" + ) + affected = self._nesting_prefixes(type_name) + instances_before = self._instances_before_edit(affected) + targets = [ + (instance_path, f"{prefix}{path}") + for name, prefixes in affected.items() + for instance_path in instances_before[name] + for prefix in prefixes + ] + self._check_creation_targets(targets) + definition.parameters[path] = _TypeEntry(default=default, unit=unit) + created: set = set() + for instance_path, relative_target in targets: + full = f"{instance_path}.{relative_target}" + if full in created: + continue + created.add(full) + if not self.has_param(full): + self.add_parameter(full, initial_value=default, unit=unit) + + def remove_type_parameter(self, type_name: str, path: str) -> None: + """Remove the entry at ``path`` from the Type ``type_name``'s own + entries (D13): the parameters of the Instances are untouched, and + the submodules that no longer carry the whole shape simply stop + being Instances (D1). + + Raises ``ValueError`` naming the path when no such Type exists, + when ``path`` is not an entry of the Type itself — naming the + Type that defines it, when the path only reaches the effective + parameter set through a Nested Type — and when it is in no + effective set at all. Nothing is removed then. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + """ + self._require_type_entry(type_name, path) + del self._types[type_name].parameters[path] + + def set_type_parameter_default( + self, type_name: str, path: str, value: Any + ) -> None: + """Set the default value of the Type ``type_name``'s own entry at + ``path`` (D13): the parameters the Instances already carry keep + their values, and only parameters created later start with the + new default. + + Raises ``ValueError`` naming the path under the same conditions + as :meth:`remove_type_parameter`; nothing is changed then. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + :param value: The entry's new default value. + """ + entry = self._require_type_entry(type_name, path) + entry.default = value + + def set_type_parameter_unit( + self, type_name: str, path: str, unit: str + ) -> None: + """Set the unit of the Type ``type_name``'s own entry at ``path`` + and propagate it to that parameter in every Instance of the Type + and of every Type whose effective parameter set contains + ``type_name`` through nesting (D13); the target of a propagation + is one of the Instance's parameters by construction, and a + parameter already carrying the new unit is simply set again. + + Raises ``ValueError`` naming the path under the same conditions + as :meth:`remove_type_parameter`; nothing is changed then. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + :param unit: The entry's and the Instances' new unit. + """ + entry = self._require_type_entry(type_name, path) + # the Instances exist before the edit; the propagation targets are + # among their parameters by construction + affected = self._nesting_prefixes(type_name) + instances_before = self._instances_before_edit(affected) + entry.unit = unit + propagated: set = set() + for name, prefixes in affected.items(): + for instance_path in instances_before[name]: + for prefix in prefixes: + full = f"{instance_path}.{prefix}{path}" + if full in propagated: + continue + propagated.add(full) + if self.has_param(full): + self.parameter(full).unit = unit + + def add_nested_type( + self, type_name: str, submodule: str, nested_type: str + ) -> None: + """Require the Nested Type ``nested_type`` at the submodule + ``submodule`` of the Type ``type_name`` (D11), and write the + nested Type's effective parameter set under that submodule into + every Instance of ``type_name`` — and of every Type nesting it — + that lacks the parameters, with each entry's default value and + unit (D13). + + Raises ``ValueError`` — leaving the registry and the parameter + tree untouched — naming every offending name or path when a Type + does not exist, when ``submodule`` is empty, has an empty segment + or starts with the reserved Globals name ``_globals`` (D18), when + the submodule already requires a Nested Type, when the nesting + would close a cycle (``type_name == nested_type`` included), when + the resulting effective parameter set of ``type_name`` or of any + Type nesting it would contain a path twice, or when a target path + cannot be created. + + :param type_name: Name of the outer Type. + :param submodule: Name of the submodule that requires the Nested + Type. + :param nested_type: Name of the Nested Type. + """ + # validate-then-mutate: every check below runs before the registry + # or the tree is touched + missing = [ + f"no Type named '{name}' exists" + for name in dict.fromkeys((type_name, nested_type)) + if name not in self._types + ] + if missing: + raise ValueError("; ".join(missing)) + definition = self._types[type_name] + if not submodule or any(segment == "" for segment in submodule.split(".")): + raise ValueError( + f"'{submodule}' is not a valid submodule name for a " + "Nested Type" + ) + if submodule.split(".")[0] == "_globals": + raise ValueError( + f"'{submodule}' is not a valid submodule name for a " + "Nested Type: the Globals submodule name is reserved" + ) + if submodule in definition.nested: + raise ValueError( + f"submodule '{submodule}' of Type '{type_name}' already " + f"requires the Nested Type '{definition.nested[submodule]}'" + ) + # the cycle check runs on a copied registry holding the candidate + # definition, so a refusal leaves the real one untouched + candidate = _TypeDefinition( + name=definition.name, + parameters=dict(definition.parameters), + nested={**definition.nested, submodule: nested_type}, + ) + candidate_registry = dict(self._types) + candidate_registry[type_name] = candidate + cycle = self._nested_cycle(candidate, types=candidate_registry) + if cycle is not None: + raise ValueError( + f"cannot nest Type '{nested_type}' at submodule " + f"'{submodule}' of Type '{type_name}': cycle in nested " + f"Types: {' -> '.join(cycle)}" + ) + # the resulting effective parameter set of the edited Type and of + # every Type nesting it must not contain a path twice; computed + # against the current registry, which the mutation below follows + affected = self._nesting_prefixes(type_name) + nested_entries = self._effective_entries(nested_type) + collisions: List[str] = [] + for name in affected: + current = set(self._expand_effective(name)) + new_paths: List[str] = [] + for prefix in affected[name]: + for entry_path in nested_entries: + new_path = f"{prefix}{submodule}.{entry_path}" + if new_path in current or new_path in new_paths: + described = ( + f"'{new_path}' (in the effective set of " + f"Type '{name}')" + ) + if described not in collisions: + collisions.append(described) + else: + new_paths.append(new_path) + if collisions: + raise ValueError( + f"cannot nest Type '{nested_type}' at submodule " + f"'{submodule}' of Type '{type_name}': parameter path(s) " + f"{', '.join(collisions)} would appear more than once" + ) + instances_before = self._instances_before_edit(affected) + targets = [ + (instance_path, f"{prefix}{submodule}.{entry_path}", entry) + for name, prefixes in affected.items() + for instance_path in instances_before[name] + for prefix in prefixes + for entry_path, entry in nested_entries.items() + ] + self._check_creation_targets( + [(instance_path, relative_target) for instance_path, relative_target, _ in targets] + ) + definition.nested[submodule] = nested_type + created: set = set() + for instance_path, relative_target, entry in targets: + full = f"{instance_path}.{relative_target}" + if full in created: + continue + created.add(full) + if not self.has_param(full): + self.add_parameter( + full, initial_value=entry.default, unit=entry.unit + ) + + def remove_nested_type(self, type_name: str, submodule: str) -> None: + """Remove the Nested Type required at the submodule ``submodule`` + of the Type ``type_name`` (D13): the parameters of the Instances + are untouched, and the submodules that no longer carry the whole + shape simply stop being Instances (D1). + + Raises ``ValueError`` naming the Type and the submodule when no + such Type exists or the submodule requires no Nested Type; + nothing is removed then. + + :param type_name: Name of the Type. + :param submodule: Name of the submodule that requires the Nested + Type. + """ + definition = self._require_type(type_name) + if submodule not in definition.nested: + raise ValueError( + f"submodule '{submodule}' of Type '{type_name}' has no " + "Nested Type" + ) + del definition.nested[submodule] + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 606fd88..df3454a 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -1,19 +1,25 @@ -"""Tests for the Type registry and definitions (plan task 2.1) and for -the duck-typed Instance matching (plan task 2.2). - -The public ``add_type`` / ``remove_type`` / ``list_types`` / ``get_type`` -methods are exercised directly. The editing methods for entries and Nested -Types arrive with plan task 2.3, so the tests that need Types with entries -or nesting insert their ``_TypeDefinition`` registry records by hand. -Covered: creating, listing, getting and removing Types (with the reserved -Globals name and duplicate-name refusals), the effective parameter set -expansion of Nested Types (cycle refusal, collision refusal), the content -of the ``PMTypeBluePrint`` ``get_type`` returns, its round-trip through -the blueprint serialization, and the Instance matching queries -``instances_of`` and ``types_of`` (existence and unit, any depth, never -the root, never the Globals submodule, ordering of the claiming Types). +"""Tests for the Type registry and definitions (plan task 2.1), the +duck-typed Instance matching (plan task 2.2) and the Type edits with +Instance side effects (plan task 2.3). + +The definition and editing methods are exercised through the public API: +``add_type`` / ``add_type_parameter`` / ``add_nested_type`` and friends. +The states the public API refuses to build — Nested Type cycles, Nested +Types missing from the registry, effective sets with a duplicated path — +are still inserted into the registry by hand with the ``put_type`` +helper. +Covered: creating, listing, getting and removing Types (with the +reserved Globals name and duplicate-name refusals), the effective +parameter set expansion of Nested Types (cycle refusal, collision +refusal), the content of the ``PMTypeBluePrint`` ``get_type`` returns, +its round-trip through the blueprint serialization, the Instance matching +queries ``instances_of`` and ``types_of`` (existence and unit, any depth, +never the root, never the Globals submodule, ordering of the claiming +Types), and the six editing methods with their D13 Instance side effects, +every refusal leaving the registry and the parameter tree byte-identical. """ +import copy import re import pytest @@ -30,8 +36,9 @@ def pm(tmp_path, monkeypatch): def put_type(pm, name, parameters=None, nested=None): - """Insert a ``_TypeDefinition`` into the registry directly: the public - editing API for entries and Nested Types arrives with plan task 2.3.""" + """Insert a ``_TypeDefinition`` into the registry directly: for the + states the public editing API refuses to build (Nested Type cycles, + missing Nested Types, effective sets with a duplicated path).""" pm._types[name] = _TypeDefinition( name=name, parameters={ @@ -45,29 +52,16 @@ def put_three_tier_registry(pm): """The mock's three-tier case: ``qubit`` nests ``readout`` at its submodule ``readout``, which nests ``pulse_window`` at its submodule ``pw``.""" - put_type( - pm, - "qubit", - parameters={ - "IF": {"default": None, "unit": "Hz"}, - "octave_gain": {"default": 10, "unit": "dB"}, - }, - nested={"readout": "readout"}, - ) - put_type( - pm, - "readout", - parameters={ - "IF": {"default": None, "unit": "Hz"}, - "window": {"default": None, "unit": "s"}, - }, - nested={"pw": "pulse_window"}, - ) - put_type( - pm, - "pulse_window", - parameters={"duration": {"default": None, "unit": "s"}}, - ) + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type_parameter("readout", "window", default=None, unit="s") + pm.add_nested_type("readout", "pw", "pulse_window") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") # --------------------------------------------------------------------------- @@ -142,8 +136,10 @@ def test_remove_type_with_an_unknown_name_raises_naming_it(pm): def test_remove_type_refused_while_nested_in_another_type(pm): - put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) - put_type(pm, "qubit", nested={"readout": "readout"}) + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") with pytest.raises( ValueError, @@ -159,9 +155,11 @@ def test_remove_type_refused_while_nested_in_another_type(pm): def test_remove_type_names_every_type_nesting_it(pm): - put_type(pm, "readout") - put_type(pm, "qubit", nested={"readout": "readout"}) - put_type(pm, "qubit2", nested={"readout": "readout"}) + pm.add_type("readout") + pm.add_type("qubit") + pm.add_type("qubit2") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_nested_type("qubit2", "readout", "readout") with pytest.raises(ValueError) as excinfo: pm.remove_type("readout") @@ -176,8 +174,10 @@ def test_remove_type_names_every_type_nesting_it(pm): def test_remove_type_removes_a_type_that_nests_other_types(pm): # qubit nests readout but is nested by nobody: removing it is allowed, # and the Nested Type readout stays in the registry untouched - put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) - put_type(pm, "qubit", nested={"readout": "readout"}) + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") pm.remove_type("qubit") @@ -220,8 +220,11 @@ def test_effective_set_of_the_middle_tier(pm): def test_the_same_type_nested_twice_is_not_a_cycle(pm): - put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) - put_type(pm, "qubit", nested={"ro1": "readout", "ro2": "readout"}) + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "ro1", "readout") + pm.add_nested_type("qubit", "ro2", "readout") assert pm._effective_parameters("qubit") == { "ro1.IF": {"unit": "Hz", "from_type": "readout"}, @@ -415,22 +418,12 @@ def test_types_of_orders_innermost_first(pm): def test_a_type_claiming_through_two_instances_is_ordered_by_its_innermost_instance(pm): # one Type claiming a parameter through more than one Instance is # listed once, ordered by its innermost Instance - put_type( - pm, - "zzz", - parameters={ - "x": {"default": None, "unit": "Hz"}, - "a.x": {"default": None, "unit": "Hz"}, - }, - ) - put_type( - pm, - "aaa", - parameters={ - "a.x": {"default": None, "unit": "Hz"}, - "top": {"default": None, "unit": "s"}, - }, - ) + pm.add_type("zzz") + pm.add_type_parameter("zzz", "x", default=None, unit="Hz") + pm.add_type_parameter("zzz", "a.x", default=None, unit="Hz") + pm.add_type("aaa") + pm.add_type_parameter("aaa", "a.x", default=None, unit="Hz") + pm.add_type_parameter("aaa", "top", default=None, unit="s") pm.add_parameter("a.x", unit="Hz") pm.add_parameter("a.top", unit="s") pm.add_parameter("a.a.x", unit="Hz") @@ -463,14 +456,9 @@ def test_a_unit_mismatch_excludes_the_submodule(pm): # every effective path exists, but octave_gain carries the wrong unit: # a match requires existence and unit (D12), so the whole submodule is # no Instance - put_type( - pm, - "qubit", - parameters={ - "IF": {"default": None, "unit": "Hz"}, - "octave_gain": {"default": 10, "unit": "dB"}, - }, - ) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") pm.add_parameter("q01.IF", unit="Hz") pm.add_parameter("q01.octave_gain", unit="Hz") @@ -496,7 +484,8 @@ def test_a_unit_mismatch_at_one_level_leaves_the_deeper_instances(pm): def test_extra_parameters_do_not_matter(pm): - put_type(pm, "qubit", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") pm.add_parameter("q01.IF", unit="Hz") # extra parameters and extra Parameter Groups change nothing pm.add_parameter("q01.extra", unit="dB") @@ -507,15 +496,11 @@ def test_extra_parameters_do_not_matter(pm): def test_two_types_on_one_submodule(pm): - put_type( - pm, - "readout", - parameters={ - "IF": {"default": None, "unit": "Hz"}, - "window": {"default": None, "unit": "s"}, - }, - ) - put_type(pm, "ro_small", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type_parameter("readout", "window", default=None, unit="s") + pm.add_type("ro_small") + pm.add_type_parameter("ro_small", "IF", default=None, unit="Hz") pm.add_parameter("q01.readout.IF", unit="Hz") pm.add_parameter("q01.readout.window", unit="s") @@ -529,8 +514,10 @@ def test_two_types_on_one_submodule(pm): def test_types_of_breaks_a_tie_by_type_name(pm): # two Types of the same size on the same Instance: the order falls # back to the Type name (the registry holds ro_b first) - put_type(pm, "ro_b", parameters={"IF": {"default": None, "unit": "Hz"}}) - put_type(pm, "ro_a", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_type("ro_b") + pm.add_type_parameter("ro_b", "IF", default=None, unit="Hz") + pm.add_type("ro_a") + pm.add_type_parameter("ro_a", "IF", default=None, unit="Hz") pm.add_parameter("q01.readout.IF", unit="Hz") assert pm.types_of("q01.readout.IF") == ["ro_a", "ro_b"] @@ -544,7 +531,8 @@ def test_an_empty_type_has_no_instances(pm): def test_the_root_is_never_an_instance(pm): - put_type(pm, "flat", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_type("flat") + pm.add_type_parameter("flat", "IF", default=None, unit="Hz") pm.add_parameter("IF", unit="Hz") pm.add_parameter("under_group.IF", unit="Hz") @@ -555,7 +543,8 @@ def test_the_root_is_never_an_instance(pm): def test_nothing_under_the_globals_submodule_matches(pm): - put_type(pm, "qubit", parameters={"IF": {"default": None, "unit": "Hz"}}) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") # a Parameter Group directly under Globals and one deeper inside it # both carry the whole shape with the right unit — and still match # nothing, because matching excludes the Globals subtree (D12) @@ -569,14 +558,9 @@ def test_nothing_under_the_globals_submodule_matches(pm): def test_values_are_irrelevant_to_matching(pm): - put_type( - pm, - "qubit", - parameters={ - "IF": {"default": None, "unit": "Hz"}, - "octave_gain": {"default": 10, "unit": "dB"}, - }, - ) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") pm.add_parameter("q01.IF", initial_value=1, unit="Hz") pm.add_parameter("q01.octave_gain", initial_value=999, unit="dB") pm.add_parameter("q02.IF", initial_value=5e9, unit="Hz") @@ -587,8 +571,11 @@ def test_values_are_irrelevant_to_matching(pm): def test_a_type_nested_at_two_submodules_matches_only_whole_carriers(pm): - put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) - put_type(pm, "qubit", nested={"ro1": "readout", "ro2": "readout"}) + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "ro1", "readout") + pm.add_nested_type("qubit", "ro2", "readout") pm.add_parameter("q01.ro1.IF", unit="Hz") pm.add_parameter("q01.ro2.IF", unit="Hz") # q02 carries only one of the two nested halves @@ -631,3 +618,599 @@ def test_types_of_returns_empty_for_an_unclaimed_parameter(pm): # the extra parameter is in no Type's effective set assert pm.types_of("q01.extra") == [] + + +# --------------------------------------------------------------------------- +# Type edits with Instance side effects (plan task 2.3, D13) +# --------------------------------------------------------------------------- + + +def test_add_type_parameter_creates_the_parameter_in_every_instance_lacking_it(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=11, unit="dB") + pm.add_parameter("q02.octave_gain", initial_value=12, unit="dB") + # a unit mismatch: q03 is no Instance (D12) and gets nothing + pm.add_parameter("q03.octave_gain", initial_value=13, unit="Hz") + assert pm.instances_of("qubit") == ["q01", "q02"] + + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + # created with the entry's default value and unit (D13) + assert pm.get("q01.IF") == 5e9 + assert pm.get("q02.IF") == 5e9 + assert pm.parameter("q01.IF").unit == "Hz" + assert pm.parameter("q02.IF").unit == "Hz" + assert not pm.has_param("q03.IF") + # both Instances still match, now against the grown effective set + assert pm.instances_of("qubit") == ["q01", "q02"] + assert pm.get_type("qubit").parameters["IF"] == { + "default": 5e9, + "unit": "Hz", + "target": None, + } + + +def test_add_type_parameter_leaves_an_existing_parameter_at_the_path_alone(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + # q01 carries an extra IF with its own value and unit: extra parameters + # do not matter to matching, and the new entry must not overwrite it + pm.add_parameter("q01.IF", initial_value=1, unit="V") + pm.add_parameter("q01.octave_gain", initial_value=11, unit="dB") + pm.add_parameter("q02.octave_gain", initial_value=12, unit="dB") + assert pm.instances_of("qubit") == ["q01", "q02"] + + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + # the existing parameter is left alone, whatever its unit + assert pm.get("q01.IF") == 1 + assert pm.parameter("q01.IF").unit == "V" + # q01 stops being an Instance: its IF does not carry the declared unit + assert pm.instances_of("qubit") == ["q02"] + + +def test_add_type_parameter_on_an_empty_type_creates_nothing(pm): + # an empty Type has no Instances (D12), so the edit has no side + # effects: only submodules that already carry the whole shape become + # Instances on the next matching query + pm.add_type("qubit") + pm.add_parameter("q01.anything", unit="Hz") + + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + assert not pm.has_param("q01.IF") + assert pm.list() == ["q01.anything"] + assert pm.instances_of("qubit") == [] + + +def test_add_type_parameter_reaches_the_instances_of_types_nesting_it(pm): + # qubit nests the empty readout, super nests qubit at q: adding the + # first readout entry must write readout.window into every qubit- and + # super-Instance, creating the missing readout Parameter Groups on the + # way; without the outer reach, q01 and s01 would silently stop + # matching after the edit + pm.add_type("readout") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("s01.top", initial_value=1, unit="") + pm.add_parameter("s01.q.octave_gain", initial_value=10, unit="dB") + assert pm.instances_of("qubit") == ["q01", "s01.q"] + assert pm.instances_of("super") == ["s01"] + assert pm.instances_of("readout") == [] + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + # each affected path is created once, although readout, qubit and + # super all reach it (their pairs de-duplicate to the same paths) + assert pm.has_param("q01.readout.window") + assert pm.has_param("s01.q.readout.window") + assert pm.get("q01.readout.window") == 2e-6 + assert pm.parameter("s01.q.readout.window").unit == "s" + assert pm.instances_of("readout") == ["q01.readout", "s01.q.readout"] + # every Instance still matches its Type against the grown sets + assert pm.instances_of("qubit") == ["q01", "s01.q"] + assert pm.instances_of("super") == ["s01"] + + +def test_add_type_parameter_writes_every_prefix_of_a_type_nested_twice(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "ro1", "readout") + pm.add_nested_type("qubit", "ro2", "readout") + pm.add_parameter("q01.ro1.IF", unit="Hz") + pm.add_parameter("q01.ro2.IF", unit="Hz") + assert pm.instances_of("qubit") == ["q01"] + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + # the entry is written under every submodule that requires readout + assert pm.has_param("q01.ro1.window") + assert pm.has_param("q01.ro2.window") + assert pm.get("q01.ro1.window") == 2e-6 + assert pm.instances_of("qubit") == ["q01"] + + +def test_add_type_parameter_refuses_a_path_the_type_already_defines(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'IF' is already in the effective set of " + "Type 'qubit' (defined by Type 'qubit')" + ), + ): + pm.add_type_parameter("qubit", "IF", default=1, unit="V") + + assert pm.get_type("qubit").parameters["IF"] == { + "default": None, + "unit": "Hz", + "target": None, + } + + +def test_add_type_parameter_refuses_a_path_defined_by_a_nested_type(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is already in the effective set " + "of Type 'qubit' (defined by Type 'readout')" + ), + ): + pm.add_type_parameter("qubit", "readout.IF") + + assert pm.get_type("qubit").parameters == {} + + +def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", unit="dB") + pm.add_parameter("q02.octave_gain", unit="dB") + + with pytest.raises(ValueError) as excinfo: + pm.add_type_parameter("qubit", "octave_gain.x", default=1, unit="s") + + # every offending path, not the first (rule 3) + message = str(excinfo.value) + assert "cannot create parameter 'q01.octave_gain.x'" in message + assert "cannot create parameter 'q02.octave_gain.x'" in message + assert "'q01.octave_gain' is a parameter, and cannot have child parameters" in message + assert "'q02.octave_gain' is a parameter, and cannot have child parameters" in message + # nothing was mutated + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None} + } + assert pm.list() == ["q01.octave_gain", "q02.octave_gain"] + + +def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter_group(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", unit="dB") + # the group q01.IF occupies the target path of the new entry + pm.add_parameter("q01.IF.sub", unit="s") + pm.add_parameter("q02.octave_gain", unit="dB") + + with pytest.raises( + ValueError, + match=re.escape("cannot create parameter 'q01.IF': 'q01.IF' is already a Parameter Group"), + ): + pm.add_type_parameter("qubit", "IF", default=1, unit="Hz") + + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None} + } + assert not pm.has_param("q02.IF") + + +def test_add_type_parameter_refuses_a_path_with_empty_segments(pm): + pm.add_type("qubit") + + with pytest.raises(ValueError, match="is not a valid parameter path"): + pm.add_type_parameter("qubit", "") + with pytest.raises(ValueError, match="is not a valid parameter path"): + pm.add_type_parameter("qubit", "x..y") + + assert pm.get_type("qubit").parameters == {} + assert pm.list() == [] + + +def test_remove_type_parameter_leaves_the_instance_parameters_untouched(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + pm.remove_type_parameter("qubit", "IF") + + # the parameter stays on the Instance, with value and unit (D13) + assert pm.has_param("q01.IF") + assert pm.get("q01.IF") == 5e9 + assert pm.parameter("q01.IF").unit == "Hz" + # the registry lost the entry; the shrunken shape still matches q01, + # whose removed parameter simply became an untyped row + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None} + } + assert pm.instances_of("qubit") == ["q01"] + assert pm.types_of("q01.IF") == [] + assert pm.types_of("q01.octave_gain") == ["qubit"] + + +def test_remove_type_parameter_refuses_a_path_only_reached_through_a_nested_type(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is not an entry of Type 'qubit' " + "itself: it is only in the effective set through the entry of " + "Type 'readout'" + ), + ): + pm.remove_type_parameter("qubit", "readout.IF") + + assert pm.get_type("qubit").nested == {"readout": "readout"} + assert pm.get_type("readout").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_remove_type_parameter_refuses_an_unknown_path(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + with pytest.raises( + ValueError, + match=re.escape("parameter path 'nope' is not an entry of Type 'qubit'"), + ): + pm.remove_type_parameter("qubit", "nope") + + assert pm.get_type("qubit").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_set_type_parameter_default_changes_only_the_registry(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + + pm.set_type_parameter_default("qubit", "IF", 6e9) + + assert pm.get_type("qubit").parameters["IF"]["default"] == 6e9 + # the Instance keeps its value (D13): only parameters created later + # start with the new default + assert pm.get("q01.IF") == 5e9 + assert pm.instances_of("qubit") == ["q01"] + + +def test_set_type_parameter_default_refuses_paths_that_are_not_its_own_entries(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "'readout.IF' is not an entry of Type 'qubit' itself" + ), + ): + pm.set_type_parameter_default("qubit", "readout.IF", 1) + with pytest.raises( + ValueError, + match=re.escape("'nope' is not an entry of Type 'qubit'"), + ): + pm.set_type_parameter_default("qubit", "nope", 1) + + assert pm.get_type("readout").parameters["IF"]["default"] is None + + +def test_set_type_parameter_unit_propagates_to_every_instance(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("q01.octave_gain", unit="dB") + pm.add_parameter("q01.readout.IF", unit="Hz") + pm.add_parameter("s01.top", unit="") + pm.add_parameter("s01.q.octave_gain", unit="dB") + pm.add_parameter("s01.q.readout.IF", unit="Hz") + + pm.set_type_parameter_unit("readout", "IF", "V") + + # the entry and every Instance's parameter carry the new unit — the + # same parameter, whether reached through readout, qubit or super + assert pm.get_type("readout").parameters["IF"]["unit"] == "V" + assert pm.parameter("q01.readout.IF").unit == "V" + assert pm.parameter("s01.q.readout.IF").unit == "V" + # the Instances still match, now against the new unit + assert pm.instances_of("readout") == ["q01.readout", "s01.q.readout"] + assert pm.instances_of("qubit") == ["q01", "s01.q"] + assert pm.instances_of("super") == ["s01"] + # untouched parameters keep their unit + assert pm.parameter("q01.octave_gain").unit == "dB" + + +def test_set_type_parameter_unit_refuses_paths_that_are_not_its_own_entries(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "'readout.IF' is not an entry of Type 'qubit' itself" + ), + ): + pm.set_type_parameter_unit("qubit", "readout.IF", "V") + with pytest.raises( + ValueError, + match=re.escape("'nope' is not an entry of Type 'qubit'"), + ): + pm.set_type_parameter_unit("qubit", "nope", "V") + + assert pm.get_type("readout").parameters["IF"]["unit"] == "Hz" + + +def test_add_nested_type_writes_the_nested_entries_into_the_instances(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + assert pm.instances_of("qubit") == ["q01"] + + pm.add_nested_type("qubit", "readout", "readout") + + # the nested entries are written under the submodule with their + # defaults and units (D13) + assert pm.has_param("q01.readout.IF") + assert pm.get("q01.readout.IF") == 10e6 + assert pm.get("q01.readout.window") == 2e-6 + assert pm.parameter("q01.readout.window").unit == "s" + assert pm.get_type("qubit").nested == {"readout": "readout"} + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + + +def test_add_nested_type_reaches_the_instances_of_outer_types(pm): + # qubit is empty when super nests it, so the super-Instance s01 has no + # q group at all: nesting readout into qubit must create + # s01.q.readout.IF through the missing Parameter Groups + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("s01.top", initial_value=1, unit="") + assert pm.instances_of("qubit") == [] + assert pm.instances_of("super") == ["s01"] + + pm.add_nested_type("qubit", "readout", "readout") + + assert pm.has_param("s01.q.readout.IF") + assert pm.get("s01.q.readout.IF") == 10e6 + assert pm.parameter("s01.q.readout.IF").unit == "Hz" + assert pm.instances_of("qubit") == ["s01.q"] + assert pm.instances_of("super") == ["s01"] + assert pm.instances_of("readout") == ["s01.q.readout"] + + +def test_add_nested_type_refuses_a_self_nesting(pm): + pm.add_type("loop") + + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: loop -> loop"), + ): + pm.add_nested_type("loop", "self", "loop") + + assert pm.get_type("loop").nested == {} + + +def test_add_nested_type_refuses_a_longer_cycle(pm): + pm.add_type("readout") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: readout -> qubit -> readout"), + ): + pm.add_nested_type("readout", "qubit", "qubit") + + assert pm.get_type("readout").nested == {} + assert pm.get_type("qubit").nested == {"readout": "readout"} + + +def test_add_nested_type_refuses_an_occupied_submodule(pm): + pm.add_type("readout") + pm.add_type("pulse_window") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "submodule 'readout' of Type 'qubit' already requires the " + "Nested Type 'readout'" + ), + ): + pm.add_nested_type("qubit", "readout", "pulse_window") + + assert pm.get_type("qubit").nested == {"readout": "readout"} + + +def test_add_nested_type_refuses_a_duplicated_effective_path(pm): + # qubit's own entry readout.IF would collide with the readout entry, + # both in qubit's effective set and in the one of super, which nests + # qubit at q + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "readout.IF", default=None, unit="Hz") + pm.add_type("super") + pm.add_nested_type("super", "q", "qubit") + + with pytest.raises(ValueError) as excinfo: + pm.add_nested_type("qubit", "readout", "readout") + + # every offending path, not the first (rule 3) + message = str(excinfo.value) + assert "'readout.IF' (in the effective set of Type 'qubit')" in message + assert "'q.readout.IF' (in the effective set of Type 'super')" in message + assert pm.get_type("qubit").nested == {} + assert pm.get_type("qubit").parameters == { + "readout.IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_add_nested_type_refuses_the_globals_submodule(pm): + pm.add_type("readout") + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("the Globals submodule name is reserved"), + ): + pm.add_nested_type("qubit", "_globals", "readout") + + assert pm.get_type("qubit").nested == {} + + +def test_add_nested_type_with_an_unknown_type_raises_naming_it(pm): + pm.add_type("readout") + + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.add_nested_type("qubit", "readout", "readout") + # both missing names appear in one error (rule 3) + with pytest.raises(ValueError) as excinfo: + pm.add_nested_type("qubit", "ro", "pulse_window") + assert "no Type named 'qubit' exists" in str(excinfo.value) + assert "no Type named 'pulse_window' exists" in str(excinfo.value) + + assert pm.list_types() == ["readout"] + + +def test_remove_nested_type_leaves_the_parameters_untouched(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_parameter("q01.octave_gain", unit="dB") + pm.add_parameter("q01.readout.IF", unit="Hz") + + pm.remove_nested_type("qubit", "readout") + + # every parameter stays (D13); the shrunken shape still matches q01, + # whose readout parameter simply became an untyped row + assert pm.has_param("q01.readout.IF") + assert pm.parameter("q01.readout.IF").unit == "Hz" + assert pm.get_type("qubit").nested == {} + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.types_of("q01.readout.IF") == ["readout"] + + +def test_remove_nested_type_refuses_a_submodule_without_one(pm): + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("submodule 'readout' of Type 'qubit' has no Nested Type"), + ): + pm.remove_nested_type("qubit", "readout") + + +def test_remove_type_leaves_the_parameters_untouched(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + + pm.remove_type("qubit") + + # deleting the Type touches no parameters (D13); the row is untyped + assert pm.has_param("q01.IF") + assert pm.get("q01.IF") == 5e9 + assert pm.types_of("q01.IF") == [] + + +def test_a_failed_validation_leaves_the_tree_and_registry_byte_identical(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.readout.window", initial_value=2e-6, unit="s") + pm.add_parameter("q02.octave_gain", initial_value=11, unit="dB") + pm.add_parameter("q02.readout.IF", initial_value=20e6, unit="Hz") + + def state(): + return ( + sorted(pm.list()), + {path: pm.get(path) for path in pm.list()}, + {path: pm.parameter(path).unit for path in pm.list()}, + copy.deepcopy(pm._types), + ) + + before = state() + failing_calls = [ + lambda: pm.add_type_parameter("qubit", "octave_gain"), + lambda: pm.add_type_parameter("qubit", "readout.IF"), + lambda: pm.add_type_parameter("nope", "x"), + lambda: pm.add_type_parameter("qubit", ""), + # q02.octave_gain is a parameter and blocks the target path + lambda: pm.add_type_parameter("qubit", "octave_gain.x", default=1, unit="s"), + lambda: pm.remove_type_parameter("qubit", "readout.IF"), + lambda: pm.remove_type_parameter("qubit", "nope"), + lambda: pm.set_type_parameter_default("qubit", "readout.IF", 1), + lambda: pm.set_type_parameter_default("qubit", "nope", 1), + lambda: pm.set_type_parameter_unit("qubit", "readout.IF", "V"), + lambda: pm.set_type_parameter_unit("qubit", "nope", "V"), + lambda: pm.add_nested_type("qubit", "readout", "readout"), + lambda: pm.add_nested_type("qubit", "readout", "nope"), + lambda: pm.add_nested_type("qubit", "_globals", "readout"), + lambda: pm.add_nested_type("qubit", "ro", "nope"), + lambda: pm.add_nested_type("nope", "s", "readout"), + lambda: pm.add_nested_type("readout", "qubit", "qubit"), + lambda: pm.add_nested_type("readout", "self", "readout"), + lambda: pm.remove_nested_type("qubit", "pw"), + lambda: pm.remove_nested_type("nope", "readout"), + ] + for call in failing_calls: + with pytest.raises(ValueError): + call() + # the refused call left list, every value and unit, and the Type + # registry byte-identical + assert state() == before From bd4b4576829fcf375f9065dd65832ab22859fa5b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 17:24:47 -0500 Subject: [PATCH 041/107] 2.3: fix from review round 1: refuse conflicting creation targets up front, check the new path against nesting Types, pin the add_nested_type validations --- src/instrumentserver/params.py | 52 ++++++++- test/pytest/test_pm_types.py | 194 +++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 6 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 38580a7..acae340 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -1178,8 +1178,12 @@ def _check_creation_targets( cannot have child parameters) and the final segment may not be an existing Parameter Group (a Parameter Group cannot become a parameter); a target whose final segment is an existing parameter - is fine — it is left alone. Raises ``ValueError`` naming every - offending full path.""" + is fine — it is left alone. One de-duplicated target path may also + not be a strict segment-prefix of another target of the same edit: + creating the shorter parameter would take the Parameter Group the + longer one needs, so the edit could never carry out its own + pre-check. Raises ``ValueError`` naming every offending full + path.""" offending: Dict[str, str] = {} seen: set = set() for instance_path, relative_target in targets: @@ -1212,6 +1216,22 @@ def _check_creation_targets( break assert isinstance(submodule, ParameterGroup) group = submodule + # the prefix check runs over the de-duplicated targets of this one + # edit, whatever Instance or nesting chain produced them + full_paths = sorted(seen) + for index, shorter in enumerate(full_paths): + for longer in full_paths[index + 1:]: + if longer.startswith(f"{shorter}."): + blocked, blocker = longer, shorter + elif shorter.startswith(f"{longer}."): + blocked, blocker = shorter, longer + else: + continue + offending.setdefault( + blocked, + f"'{blocker}' is also created by this edit, and a " + "parameter cannot have child parameters", + ) if offending: details = "; ".join( f"cannot create parameter '{path}': {reason}" @@ -1262,9 +1282,13 @@ def add_type_parameter( tree untouched — naming every offending path when no such Type exists, when ``path`` is empty or has an empty segment, when ``path`` is already in the Type's effective parameter set (naming - the Type that defines it), or when a target path cannot be - created because an intermediate segment is an existing parameter - or the final segment is an existing Parameter Group. + the Type that defines it), when ``prefix + path`` is already in + the effective parameter set of a Type nesting this one (naming + that Type and the path: the duplicated path would break every + query on it), or when a target path cannot be created because an + intermediate segment is an existing parameter, the final segment + is an existing Parameter Group, or another target of the same + edit is a strict segment-prefix of it. :param type_name: Name of the Type. :param path: Relative parameter path of the entry. @@ -1286,6 +1310,20 @@ def add_type_parameter( f"of Type '{type_name}' (defined by Type '{from_type}')" ) affected = self._nesting_prefixes(type_name) + # the new path must not collide in the effective parameter set of + # a Type nesting this one either — the same check the Nested Type + # edit runs for its candidate sets (the edited Type's own case is + # handled by the check above) + for name in affected: + current = set(self._expand_effective(name)) + for prefix in affected[name]: + candidate = f"{prefix}{path}" + if candidate in current: + raise ValueError( + f"cannot add '{path}' to Type '{type_name}': " + f"parameter path '{candidate}' would appear more " + f"than once in the effective set of Type '{name}'" + ) instances_before = self._instances_before_edit(affected) targets = [ (instance_path, f"{prefix}{path}") @@ -1392,7 +1430,9 @@ def add_nested_type( would close a cycle (``type_name == nested_type`` included), when the resulting effective parameter set of ``type_name`` or of any Type nesting it would contain a path twice, or when a target path - cannot be created. + cannot be created because an intermediate segment is an existing + parameter, the final segment is an existing Parameter Group, or + another target of the same edit is a strict segment-prefix of it. :param type_name: Name of the outer Type. :param submodule: Name of the submodule that requires the Nested diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index df3454a..14f96f1 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -775,6 +775,32 @@ def test_add_type_parameter_refuses_a_path_defined_by_a_nested_type(pm): assert pm.get_type("qubit").parameters == {} +def test_add_type_parameter_refuses_a_path_that_collides_in_a_nesting_type(pm): + # qubit nests the empty readout and owns the entry readout.window: + # adding window to readout would duplicate the path in qubit's + # effective set and break every query on qubit + pm.add_type("readout") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_type_parameter("qubit", "readout.window", default=None, unit="s") + + with pytest.raises( + ValueError, + match=re.escape( + "cannot add 'window' to Type 'readout': parameter path " + "'readout.window' would appear more than once in the " + "effective set of Type 'qubit'" + ), + ): + pm.add_type_parameter("readout", "window") + + # refused before any mutation + assert pm.get_type("readout").parameters == {} + assert pm.get_type("qubit").parameters == { + "readout.window": {"default": None, "unit": "s", "target": None} + } + + def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter(pm): pm.add_type("qubit") pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") @@ -1023,6 +1049,79 @@ def test_add_nested_type_reaches_the_instances_of_outer_types(pm): assert pm.instances_of("readout") == ["s01.q.readout"] +def test_add_nested_type_builds_the_three_tier_case(pm): + # a Nested Type that itself nests a Type, built entirely through the + # public API: nesting readout into qubit writes readout's whole + # effective set, pulse_window's entries included + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_nested_type("readout", "pw", "pulse_window") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + pm.add_nested_type("qubit", "readout", "readout") + + assert pm.has_param("q01.readout.IF") + assert pm.get("q01.readout.IF") is None + assert pm.parameter("q01.readout.IF").unit == "Hz" + assert pm.has_param("q01.readout.pw.duration") + assert pm.get("q01.readout.pw.duration") is None + assert pm.parameter("q01.readout.pw.duration").unit == "s" + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("pulse_window") == ["q01.readout.pw"] + + +def test_add_nested_type_leaves_an_existing_parameter_at_the_target_alone(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + # q01 carries a readout.IF of its own, with another value and unit + pm.add_parameter("q01.readout.IF", initial_value=1, unit="V") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + assert pm.instances_of("qubit") == ["q01"] + + pm.add_nested_type("qubit", "readout", "readout") + + # the existing parameter is left alone, whatever its unit + assert pm.get("q01.readout.IF") == 1 + assert pm.parameter("q01.readout.IF").unit == "V" + # q01 stops being an Instance: its readout.IF does not carry the unit + # the entry declares + assert pm.instances_of("qubit") == [] + + +def test_add_nested_type_accepts_a_dotted_submodule_name(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + pm.add_nested_type("qubit", "ro.deep", "readout") + + assert pm.get_type("qubit").nested == {"ro.deep": "readout"} + assert pm._effective_parameters("qubit")["ro.deep.IF"] == { + "unit": "Hz", + "from_type": "readout", + } + # the entries are written under the dotted submodule, with the entry + # default and unit + assert pm.get("q01.ro.deep.IF") == 10e6 + assert pm.parameter("q01.ro.deep.IF").unit == "Hz" + assert pm.instances_of("qubit") == ["q01"] + + # removing the Nested Type is symmetric + pm.remove_nested_type("qubit", "ro.deep") + assert pm.get_type("qubit").nested == {} + # the created parameter stays (D13) + assert pm.has_param("q01.ro.deep.IF") + + def test_add_nested_type_refuses_a_self_nesting(pm): pm.add_type("loop") @@ -1068,6 +1167,75 @@ def test_add_nested_type_refuses_an_occupied_submodule(pm): assert pm.get_type("qubit").nested == {"readout": "readout"} +def test_add_nested_type_refuses_a_target_blocked_by_a_parameter_group(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", unit="dB") + # the Parameter Group q01.readout.IF occupies the target path of the + # nested entry IF + pm.add_parameter("q01.readout.IF.sub", unit="s") + + with pytest.raises( + ValueError, + match=re.escape( + "cannot create parameter 'q01.readout.IF': 'q01.readout.IF' " + "is already a Parameter Group" + ), + ): + pm.add_nested_type("qubit", "readout", "readout") + + # nothing was mutated + assert pm.get_type("qubit").nested == {} + assert pm.list() == ["q01.readout.IF.sub", "q01.octave_gain"] + + +def test_add_nested_type_refuses_conflicting_creation_targets(pm): + # the Nested Type's effective set holds b and the strict extension + # b.c: the one edit would create the parameter q01.s.b and need it as + # a Parameter Group for q01.s.b.c — refused before anything is + # mutated, after which the creation would have raised mid-way + pm.add_type("leaf") + pm.add_type_parameter("leaf", "c", default=1, unit="V") + pm.add_type("branched") + pm.add_type_parameter("branched", "b", default=2, unit="A") + pm.add_nested_type("branched", "b", "leaf") + pm.add_type("outer") + pm.add_type_parameter("outer", "top", default=0, unit="") + pm.add_parameter("q01.top", initial_value=0, unit="") + assert pm.instances_of("outer") == ["q01"] + + with pytest.raises(ValueError) as excinfo: + pm.add_nested_type("outer", "s", "branched") + + # the offending pair is named, both paths (rule 3) + message = str(excinfo.value) + assert "cannot create parameter 'q01.s.b.c'" in message + assert "'q01.s.b' is also created by this edit" in message + # refused before any mutation + assert pm.get_type("outer").nested == {} + assert pm.list() == ["q01.top"] + + +def test_add_nested_type_refuses_a_submodule_name_with_empty_segments(pm): + pm.add_type("readout") + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("'' is not a valid submodule name for a Nested Type"), + ): + pm.add_nested_type("qubit", "", "readout") + with pytest.raises( + ValueError, + match=re.escape("'x..y' is not a valid submodule name for a Nested Type"), + ): + pm.add_nested_type("qubit", "x..y", "readout") + + assert pm.get_type("qubit").nested == {} + + def test_add_nested_type_refuses_a_duplicated_effective_path(pm): # qubit's own entry readout.IF would collide with the readout entry, # both in qubit's effective set and in the one of super, which nests @@ -1175,6 +1343,22 @@ def test_a_failed_validation_leaves_the_tree_and_registry_byte_identical(pm): pm.add_parameter("q01.readout.window", initial_value=2e-6, unit="s") pm.add_parameter("q02.octave_gain", initial_value=11, unit="dB") pm.add_parameter("q02.readout.IF", initial_value=20e6, unit="Hz") + # a Nested Type whose effective set holds b and the strict extension + # b.c, built through the public API while it has no Instances + pm.add_type("leaf") + pm.add_type_parameter("leaf", "c", default=1, unit="V") + pm.add_type("branched") + pm.add_type_parameter("branched", "b", default=2, unit="A") + pm.add_nested_type("branched", "b", "leaf") + # an empty Nested Type whose nester owns an entry under it + pm.add_type("bare") + pm.add_type("holder") + pm.add_type_parameter("holder", "bare.window", default=None, unit="s") + pm.add_nested_type("holder", "bare", "bare") + # an outer Type with an Instance, for the refused nesting below + pm.add_type("outer") + pm.add_type_parameter("outer", "top", default=0, unit="") + pm.add_parameter("q01.top", initial_value=0, unit="") def state(): return ( @@ -1192,6 +1376,9 @@ def state(): lambda: pm.add_type_parameter("qubit", ""), # q02.octave_gain is a parameter and blocks the target path lambda: pm.add_type_parameter("qubit", "octave_gain.x", default=1, unit="s"), + # the path collides in the effective set of holder, which nests + # the empty bare at bare + lambda: pm.add_type_parameter("bare", "window"), lambda: pm.remove_type_parameter("qubit", "readout.IF"), lambda: pm.remove_type_parameter("qubit", "nope"), lambda: pm.set_type_parameter_default("qubit", "readout.IF", 1), @@ -1205,6 +1392,13 @@ def state(): lambda: pm.add_nested_type("nope", "s", "readout"), lambda: pm.add_nested_type("readout", "qubit", "qubit"), lambda: pm.add_nested_type("readout", "self", "readout"), + # q01.octave_gain is a parameter and blocks the nested targets + lambda: pm.add_nested_type("qubit", "octave_gain", "readout"), + # the edit would create q01.s.b and need it as a Parameter Group + # for q01.s.b.c: the targets of one edit conflict with each other + lambda: pm.add_nested_type("outer", "s", "branched"), + lambda: pm.add_nested_type("qubit", "", "readout"), + lambda: pm.add_nested_type("qubit", "x..y", "readout"), lambda: pm.remove_nested_type("qubit", "pw"), lambda: pm.remove_nested_type("nope", "readout"), ] From bcaaa779cae186e39d54af1300094c9aba54e206 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 19:44:06 -0500 Subject: [PATCH 042/107] 2.3: fix from review round 2: name every nester collision in add_type_parameter --- src/instrumentserver/params.py | 27 +++++++++++++++++------- test/pytest/test_pm_types.py | 38 +++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index acae340..af2aad8 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -1283,9 +1283,10 @@ def add_type_parameter( exists, when ``path`` is empty or has an empty segment, when ``path`` is already in the Type's effective parameter set (naming the Type that defines it), when ``prefix + path`` is already in - the effective parameter set of a Type nesting this one (naming - that Type and the path: the duplicated path would break every - query on it), or when a target path cannot be created because an + the effective parameter set of a Type nesting this one — naming + every colliding path and the Type whose effective set holds it, + since the duplicated path would break every query on that Type — + or when a target path cannot be created because an intermediate segment is an existing parameter, the final segment is an existing Parameter Group, or another target of the same edit is a strict segment-prefix of it. @@ -1313,17 +1314,27 @@ def add_type_parameter( # the new path must not collide in the effective parameter set of # a Type nesting this one either — the same check the Nested Type # edit runs for its candidate sets (the edited Type's own case is - # handled by the check above) + # handled by the check above); every colliding path and the Type + # whose effective set holds it are collected, so one error can + # name them all (rule 3) + collisions: List[str] = [] for name in affected: current = set(self._expand_effective(name)) for prefix in affected[name]: candidate = f"{prefix}{path}" if candidate in current: - raise ValueError( - f"cannot add '{path}' to Type '{type_name}': " - f"parameter path '{candidate}' would appear more " - f"than once in the effective set of Type '{name}'" + described = ( + f"'{candidate}' (in the effective set of " + f"Type '{name}')" ) + if described not in collisions: + collisions.append(described) + if collisions: + raise ValueError( + f"cannot add '{path}' to Type '{type_name}': parameter " + f"path(s) {', '.join(collisions)} would appear more than " + "once" + ) instances_before = self._instances_before_edit(affected) targets = [ (instance_path, f"{prefix}{path}") diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 14f96f1..ae003e9 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -787,9 +787,9 @@ def test_add_type_parameter_refuses_a_path_that_collides_in_a_nesting_type(pm): with pytest.raises( ValueError, match=re.escape( - "cannot add 'window' to Type 'readout': parameter path " - "'readout.window' would appear more than once in the " - "effective set of Type 'qubit'" + "cannot add 'window' to Type 'readout': parameter path(s) " + "'readout.window' (in the effective set of Type 'qubit') " + "would appear more than once" ), ): pm.add_type_parameter("readout", "window") @@ -801,6 +801,30 @@ def test_add_type_parameter_refuses_a_path_that_collides_in_a_nesting_type(pm): } +def test_add_type_parameter_names_every_nesting_type_collision(pm): + # one nester nesting the edited Type at two submodules, owning an + # entry under each: both colliding paths are named (rule 3) + pm.add_type("inner") + pm.add_type("outer") + pm.add_nested_type("outer", "a", "inner") + pm.add_nested_type("outer", "b", "inner") + pm.add_type_parameter("outer", "a.x", default=None, unit="Hz") + pm.add_type_parameter("outer", "b.x", default=None, unit="Hz") + + with pytest.raises(ValueError) as excinfo: + pm.add_type_parameter("inner", "x") + + message = str(excinfo.value) + assert "'a.x' (in the effective set of Type 'outer')" in message + assert "'b.x' (in the effective set of Type 'outer')" in message + # refused before any mutation + assert pm.get_type("inner").parameters == {} + assert pm.get_type("outer").parameters == { + "a.x": {"default": None, "unit": "Hz", "target": None}, + "b.x": {"default": None, "unit": "Hz", "target": None}, + } + + def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter(pm): pm.add_type("qubit") pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") @@ -1350,11 +1374,14 @@ def test_a_failed_validation_leaves_the_tree_and_registry_byte_identical(pm): pm.add_type("branched") pm.add_type_parameter("branched", "b", default=2, unit="A") pm.add_nested_type("branched", "b", "leaf") - # an empty Nested Type whose nester owns an entry under it + # an empty Nested Type whose nester owns an entry under it, at two + # submodules: adding the entry to the empty Type collides twice pm.add_type("bare") pm.add_type("holder") pm.add_type_parameter("holder", "bare.window", default=None, unit="s") pm.add_nested_type("holder", "bare", "bare") + pm.add_nested_type("holder", "bare2", "bare") + pm.add_type_parameter("holder", "bare2.window", default=None, unit="s") # an outer Type with an Instance, for the refused nesting below pm.add_type("outer") pm.add_type_parameter("outer", "top", default=0, unit="") @@ -1377,7 +1404,8 @@ def state(): # q02.octave_gain is a parameter and blocks the target path lambda: pm.add_type_parameter("qubit", "octave_gain.x", default=1, unit="s"), # the path collides in the effective set of holder, which nests - # the empty bare at bare + # the empty bare at bare and bare2 and owns an entry under each: + # both colliding paths are named lambda: pm.add_type_parameter("bare", "window"), lambda: pm.remove_type_parameter("qubit", "readout.IF"), lambda: pm.remove_type_parameter("qubit", "nope"), From 3969fd132743439e6dc81641cdcb38d1d213bf83 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 19:51:55 -0500 Subject: [PATCH 043/107] 2.3: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 50 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 6a604d9..b6c5580 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -299,3 +299,53 @@ The root `ParameterManager` now has a Type registry, `self._types`, which maps e ### Process notes - Reviewers checked their findings with scratch scripts in `orchestration/2.2/` in both rounds. The orchestrator read each script before allowing it to run, and the reviewers deleted them afterwards. One scratch tempdir left under `round-0/` was removed by the orchestrator. There were no rejected permissions, no stalls and no nudges. + +## 2.3 Type edits with Instance side effects — 2026-09-24 + +`ParameterManager` now has the six D16 Type edits: `add_type_parameter`, `remove_type_parameter`, `set_type_parameter_default`, `set_type_parameter_unit`, `add_nested_type` and `remove_nested_type`. Each one validates everything before it touches the registry or the tree. The side effects follow D13 and key off the Instances that exist before the edit (`_instances_before_edit`), both the edited Type's and those of every Type nesting it (`_nesting_prefixes`). Missing parameters are created once each through the root's `add_parameter`, with the entry's default and unit, and a parameter already at a target path is left alone. `_check_creation_targets` refuses targets that cannot be created. `_effective_parameters` now sits on the new `_expand_effective`/`_effective_entries`, which also carry the full `_TypeEntry` and the defining Type. The edits emit no Broadcast yet (2.5), and `_TypeEntry.target` stays `None` (Phase 3). `test/pytest/test_pm_types.py` grew from 38 to 75 server-free tests. They include one effect test per D13 row and `test_a_failed_validation_leaves_the_tree_and_registry_byte_identical`, which compares `list()`, every value and unit, and a deep copy of `_types` around each failing call. + +### Commit by commit +- `6724148` The six methods, their helpers and 29 tests. The orchestrator wrote nine readings into the coder spec for points the plan leaves open: + - side effects target the Instances found before the edit, including those of outer Types + - an existing parameter is never overwritten + - `remove_type_parameter` and the two `set_type_parameter_*` methods act on the Type's own entries only, and a path reached only through a Nested Type raises, naming the Type that defines it + - `add_nested_type` refuses cycles (checked with `_nested_cycle` on a copied registry), an occupied submodule, `_globals` as the first submodule segment, and a duplicated path in the effective set of the Type or of any Type nesting it + - no Broadcasts and no Target handling + + With cycles refused, the 2.1 self-nesting `remove_type` case can no longer be built through the API. `test_add_nested_type_refuses_a_self_nesting` pins that. The coder added three readings of its own: an empty entry path or empty segment is refused, a target whose final segment is an existing Parameter Group is refused, and dotted submodule names are accepted. All six reviewers judged these in scope. The 2.1/2.2 tests now build their Types through the public API. `put_type` is kept only for states the API refuses to build. Orchestrator run: ruff clean, 67 in `test_pm_types.py`, 305 in the full suite. +- `bd4b457` Fix from round 0, seven items: + - `add_nested_type` could raise half-way through. When the Nested Type's effective set held a path and a dotted extension of it (`b` and `b.c`, buildable while the Type has no Instances), each target passed the pre-check on its own. The registry was written, `q01.s.b` was created, and then `q01.s.b.c` failed. plan-checker-glm and reviewer-glm both reproduced it (must-fix). `_check_creation_targets` now refuses a target that is a strict segment-prefix of another target of the same edit, and names both. The fix list also allowed refusing this shape when the Type is defined. The coder did not do that, because the fix list's own test scenario needs `{b, b.c}` to be buildable. New test: `test_add_nested_type_refuses_conflicting_creation_targets`. + - `add_type_parameter` checked the new path only against the edited Type's own effective set. If `qubit` nests the empty `readout` and owns `readout.window`, then `add_type_parameter("readout", "window")` succeeded, and every later `qubit` query raised the duplicated-path error. plan-checker-glm raised this (must-fix) and the orchestrator confirmed it in the code. The method now refuses `prefix + path` already in any nesting Type's effective set. New test: `test_add_type_parameter_refuses_a_path_that_collides_in_a_nesting_type`. + - Five test-only pins: + - `test_add_nested_type_refuses_a_target_blocked_by_a_parameter_group`: test-reviewer-glm, who showed by mutation that turning off the check left the suite green + - `test_add_nested_type_accepts_a_dotted_submodule_name`: both test reviewers + - `test_add_nested_type_refuses_a_submodule_name_with_empty_segments`: test-reviewer-glm and plan-checker-qwen + - `test_add_nested_type_builds_the_three_tier_case`: test-reviewer-qwen + - `test_add_nested_type_leaves_an_existing_parameter_at_the_target_alone`: reviewer-qwen + + The byte-identical test gained five failing calls. The coder briefly turned off `_check_creation_targets` locally to show that a test catches it. The orchestrator checked that the commit left no trace of this. + + Orchestrator run: ruff clean, 74 in `test_pm_types.py`, 312 in the full suite. +- `bcaaa77` Fix from round 1: the nester check added in `bd4b457` raised on the first collision, so it named only one nester or one path. Plan rule 3 asks for all of them. Four reviewers in three roles caught it (plan-checker-glm, plan-checker-qwen, reviewer-qwen, test-reviewer-qwen). The round-0 fix list had asked for the singular ("naming the nester and the path"), and the orchestrator ruled that the plan rule wins. The check now collects every `(path, nester)` pair and raises once, in the same format `add_nested_type` uses. New test: `test_add_type_parameter_names_every_nesting_type_collision` (one nester nesting the edited Type at `a` and `b`). The byte-identical test's `holder` setup was widened to two submodules. In re-review all six approved, and all four raisers confirmed the fix. Orchestrator run: ruff clean, 75 in `test_pm_types.py`, 313 in the full suite. + +### Dropped findings +- `_instances_before_edit` takes the Type → prefixes map but uses only its keys. The `new_path in new_paths` half of `add_nested_type`'s collision check can never be true. The `created` sets have a bare `set` annotation (reviewer-qwen, nits) → not sent. +- The `elif shorter.startswith(...)` branch of `bd4b457`'s prefix check can never run, because the paths are sorted (reviewer-qwen, plan-checker-qwen, nits; plan-checker-glm noted it too) → not sent. It is still in `_check_creation_targets`. +- No `isinstance(..., ManagedParameter)` assertion on the created parameters, and no test that the edits emit nothing (test-reviewer-glm, nits) → not sent; 2.5 tests the emissions. +- No per-method test that `remove_type_parameter` and the two `set_type_parameter_*` methods refuse an unknown Type (plan-checker-qwen, nit) → not sent. They share `_require_type`, which is already pinned. +- No dedicated test for two different nesters colliding on the same path (test-reviewer-qwen, round 2 nit) → not sent. The fix list allowed either case, the coder chose one nester at two submodules, and the code path is the same. + +### Questions to Marcos +- The orchestrator flagged the nine coder-spec readings for the run report. It also flagged that a Type whose effective set holds both `b` and `b.c` can still be built while it has no Instances, and can never match. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- For 3.1: a Type entry path may contain a `_globals` segment (`add_type_parameter("qubit", "_globals.x")` is accepted), and a non-first `_globals` segment in a submodule name is accepted too. D12/D18 do not say whether this is allowed (plan-checker-qwen, reviewer-qwen). Side effects go through `add_parameter`, so 3.1's refusal there will reach them, but `add_type_parameter`'s own validation should match it. +- A `{b, b.c}` Type stays in the registry without harm: every edit that would write it into the tree is now refused before it changes anything (reviewer-glm). +- For `set_type_parameter_unit`, reaching the outer Types' Instances changes nothing that can be seen: every such Instance already contains an Instance of the nested Type. test-reviewer-glm recorded this so that nobody counts it as coverage. +- The 2.2 question of whether a parameter without a unit (`None`) carries a declared `""` is still open. 2.3 creates side-effect parameters with the entry's unit, `""` by default. +- Nothing from 2.3 is in `TEST_AUDIT.md`. + +### Process notes +- The coder went idle after about 6 minutes of thinking, with no edits and no worker_done. The orchestrator nudged it to continue. +- In round 1, all five reviewers still running stopped with "Cannot connect to API" during a short Lumen outage. The orchestrator nudged each of them to resume. +- test-reviewer-qwen asked for `/tmp` in round 0. This was rejected, and it was told to use `orchestration/2.3/`. Reviewers used scratch scripts under `orchestration/2.3/` in every round, and each was checked for writes before it ran and deleted afterwards. reviewer-qwen made several read-only `python -c` qcodes lookups, which its role file advises against. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 9c1461a..37ce543 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -484,7 +484,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session set. Tests: `test_pm_types.py` — the three-tier case from the mock (`qubit` nests `readout` nests `pulse_window`), unit mismatch excludes, extra parameters don't matter, two Types on one submodule, `q01.readout` is an Instance of `readout` on its own. -- [ ] **2.3 Type edits with Instance side effects.** `add_type_parameter` (creates in every +- [x] **2.3 Type edits with Instance side effects.** `add_type_parameter` (creates in every Instance lacking it, with default and unit; raises if in the effective set already), `remove_type_parameter`, `set_type_parameter_default`, `set_type_parameter_unit` (propagates to every Instance's parameter), `add_nested_type` (writes missing entries under From 23b42e2d2f3a4339bd67ec83ae59d23f787ac1ab Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 20:04:03 -0500 Subject: [PATCH 044/107] 2.4: add_instance with up-front unit-conflict scan, dotted names and Globals refusal --- src/instrumentserver/params.py | 148 +++++++++++++++--- test/pytest/test_pm_types.py | 267 ++++++++++++++++++++++++++++++++- 2 files changed, 390 insertions(+), 25 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index af2aad8..d8485c2 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -1157,33 +1157,26 @@ def walk(name: str, prefix: str, branch: Tuple[str, ...]) -> None: walk(type_name, "", (type_name,)) return prefixes - def _group_at(self, path: str) -> "ParameterGroup": - """The Parameter Group at a dotted path relative to this Parameter - Manager (the root itself for the empty path).""" - group: ParameterGroup = self - for segment in path.split("."): - if segment: - submodule = group.submodules[segment] - assert isinstance(submodule, ParameterGroup) - group = submodule - return group - def _check_creation_targets( self, targets: List[Tuple[str, str]] ) -> None: - """Validate the parameters a Type edit is about to create as side - effects, before anything is mutated. ``targets`` holds + """Validate the parameters a Type edit or :meth:`add_instance` is + about to create, before anything is mutated. ``targets`` holds ``(Instance path, relative target path)`` pairs. An intermediate segment of a target may not be an existing parameter (a parameter cannot have child parameters) and the final segment may not be an existing Parameter Group (a Parameter Group cannot become a parameter); a target whose final segment is an existing parameter - is fine — it is left alone. One de-duplicated target path may also - not be a strict segment-prefix of another target of the same edit: - creating the shorter parameter would take the Parameter Group the - longer one needs, so the edit could never carry out its own - pre-check. Raises ``ValueError`` naming every offending full - path.""" + is fine — it is left alone. The Instance path itself is walked + first: a segment of it that is an existing parameter blocks every + target below it, while a Parameter Group missing along it is + created on the way, so nothing below it can clash (this is how + ``add_instance`` names Parameter Groups that do not exist yet). + One de-duplicated target path may also not be a strict + segment-prefix of another target of the same edit: creating the + shorter parameter would take the Parameter Group the longer one + needs, so the edit could never carry out its own pre-check. + Raises ``ValueError`` naming every offending full path.""" offending: Dict[str, str] = {} seen: set = set() for instance_path, relative_target in targets: @@ -1191,7 +1184,35 @@ def _check_creation_targets( if full in seen: continue seen.add(full) - group = self._group_at(instance_path) + # walk the Instance path: every segment must be a Parameter + # Group, a missing one is created on the way, and an existing + # parameter blocks everything below it + group: ParameterGroup | None = self + blocked: str | None = None + walked: List[str] = [] + for segment in instance_path.split("."): + if not segment: + continue + assert group is not None # cleared only with an immediate break + if segment in group.parameters: + blocked = ".".join([*walked, segment]) + break + submodule = group.submodules.get(segment) + if submodule is None: + # missing Parameter Group on the Instance path: it is + # created on the way, so nothing below it can clash + group = None + break + walked.append(segment) + group = submodule + if blocked is not None: + offending[full] = ( + f"'{blocked}' is a parameter, and cannot have " + "child parameters" + ) + continue + if group is None: + continue segments = relative_target.split(".") for index, segment in enumerate(segments): last = index == len(segments) - 1 @@ -1563,6 +1584,93 @@ def remove_nested_type(self, type_name: str, submodule: str) -> None: ) del definition.nested[submodule] + # ------------------------------------------------------------------ + # Instances (plan decision D14) + # + # ``add_instance`` writes a Type's effective parameter set into one + # named Parameter Group, creating the Parameter Groups on the way. + # It validates everything first: the Type, the name, a unit conflict + # on any existing parameter, and the creation targets through + # ``_check_creation_targets``; on an error nothing is created. An + # empty Type creates nothing and has no Instances (D12). Like the + # Type edits, it emits no Broadcast yet: the ``pm-type-update`` and + # re-emitted ``parameter-creation`` arrive with the Type-editing + # broadcasts task. + # ------------------------------------------------------------------ + + def add_instance(self, type_name: str, name: str) -> None: + """Create the Instance ``name`` of the Type ``type_name`` (D14): + every effective parameter path of the Type that is missing under + ``name`` — together with the Parameter Groups on the way — is + created with the entry's default value and unit through the + ordinary :meth:`add_parameter` path, and every parameter that + exists at a target path already is kept untouched, with its own + value and unit. ``name`` is a dotted submodule path relative to + this Parameter Manager (``"q01"`` or ``"q01.readout"``), so + nested Instances are allowed. After a successful call ``name`` + carries the whole effective set with the units the Type declares, + so it is an Instance in :meth:`instances_of` — unless the Type is + empty: an empty Type has no Instances (D12), creates nothing and + raises nothing, and the submodule is not created for it. + + Raises ``ValueError`` — creating nothing — naming every offending + path when no such Type exists, when ``name`` is empty or has an + empty segment, when ``name`` starts with the reserved Globals + name ``_globals`` (D18), when a parameter already exists at an + effective path with a unit different from the one the Type + declares (the unit-conflict scan runs over every effective path + before anything is created, D14), or when a target path cannot be + created because a segment of ``name`` or of the target is an + existing parameter, or the final segment of a target is an + existing Parameter Group. + + :param type_name: Name of the Type. + :param name: Dotted submodule path of the Instance, relative to + this Parameter Manager. + """ + # validate-then-mutate: every check below runs before the tree is + # touched + self._require_type(type_name) + if not name or any(segment == "" for segment in name.split(".")): + raise ValueError( + f"'{name}' is not a valid submodule path for an Instance" + ) + if name.split(".")[0] == "_globals": + raise ValueError( + f"'{name}' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ) + effective = self._effective_entries(type_name) + if not effective: + # an empty Type has no Instances (D12): there is nothing to + # create, and the submodule is not created for it + return + # the unit-conflict scan runs over every effective path before + # anything is created (D14); every conflict is collected, so one + # error can name them all (rule 3) + conflicts: List[str] = [] + for path, entry in effective.items(): + full = f"{name}.{path}" + if self.has_param(full): + existing_unit = getattr(self.parameter(full), "unit", None) + if existing_unit != entry.unit: + conflicts.append( + f"'{full}' carries unit '{existing_unit}', the " + f"Type declares '{entry.unit}'" + ) + if conflicts: + raise ValueError( + f"cannot add an Instance of Type '{type_name}' at " + f"'{name}': " + "; ".join(conflicts) + ) + self._check_creation_targets([(name, path) for path in effective]) + for path, entry in effective.items(): + full = f"{name}.{path}" + if not self.has_param(full): + self.add_parameter( + full, initial_value=entry.default, unit=entry.unit + ) + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index ae003e9..36fd548 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -1,6 +1,7 @@ """Tests for the Type registry and definitions (plan task 2.1), the -duck-typed Instance matching (plan task 2.2) and the Type edits with -Instance side effects (plan task 2.3). +duck-typed Instance matching (plan task 2.2), the Type edits with +Instance side effects (plan task 2.3) and ``add_instance`` (plan task +2.4). The definition and editing methods are exercised through the public API: ``add_type`` / ``add_type_parameter`` / ``add_nested_type`` and friends. @@ -15,8 +16,12 @@ its round-trip through the blueprint serialization, the Instance matching queries ``instances_of`` and ``types_of`` (existence and unit, any depth, never the root, never the Globals submodule, ordering of the claiming -Types), and the six editing methods with their D13 Instance side effects, -every refusal leaving the registry and the parameter tree byte-identical. +Types), the six editing methods with their D13 Instance side effects, and +``add_instance`` (creating the missing effective entries with defaults +and units, keeping existing ones with value and unit, dotted names, the +three-tier Type of the mock, the up-front unit-conflict scan, blocked +targets, the Globals refusal, unknown and empty Types) — every refusal +leaving the registry and the parameter tree byte-identical. """ import copy @@ -25,7 +30,12 @@ import pytest from instrumentserver.blueprints import PMTypeBluePrint, deserialize_obj -from instrumentserver.params import ParameterManager, _TypeDefinition, _TypeEntry +from instrumentserver.params import ( + ManagedParameter, + ParameterManager, + _TypeDefinition, + _TypeEntry, +) @pytest.fixture @@ -1386,6 +1396,13 @@ def test_a_failed_validation_leaves_the_tree_and_registry_byte_identical(pm): pm.add_type("outer") pm.add_type_parameter("outer", "top", default=0, unit="") pm.add_parameter("q01.top", initial_value=0, unit="") + # shapes and parameters for the add_instance refusals below + pm.add_type("sensor") + pm.add_type_parameter("sensor", "sub.x", default=1, unit="V") + pm.add_type_parameter("sensor", "sub.y", default=2, unit="A") + pm.add_parameter("q03.octave_gain", initial_value=1, unit="V") + pm.add_parameter("q04.sub", initial_value=0, unit="V") + pm.add_parameter("q05.readout.window.sub", initial_value=0, unit="s") def state(): return ( @@ -1429,6 +1446,21 @@ def state(): lambda: pm.add_nested_type("qubit", "x..y", "readout"), lambda: pm.remove_nested_type("qubit", "pw"), lambda: pm.remove_nested_type("nope", "readout"), + # add_instance refusals (task 2.4): an unknown Type, an invalid + # name, the reserved Globals name, a unit conflict (q03.octave_gain + # carries unit V where qubit declares dB), a target blocked by a + # parameter (q04.sub), a target occupied by a Parameter Group + # (q05.readout.window), and the conflicting targets b / b.c of + # branched + lambda: pm.add_instance("nope", "q01"), + lambda: pm.add_instance("qubit", ""), + lambda: pm.add_instance("qubit", "x..y"), + lambda: pm.add_instance("qubit", "_globals"), + lambda: pm.add_instance("qubit", "_globals.deep"), + lambda: pm.add_instance("qubit", "q03"), + lambda: pm.add_instance("sensor", "q04"), + lambda: pm.add_instance("qubit", "q05"), + lambda: pm.add_instance("branched", "q06"), ] for call in failing_calls: with pytest.raises(ValueError): @@ -1436,3 +1468,228 @@ def state(): # the refused call left list, every value and unit, and the Type # registry byte-identical assert state() == before + + +# --------------------------------------------------------------------------- +# add_instance (plan task 2.4, D14) +# --------------------------------------------------------------------------- + + +def test_add_instance_creates_the_missing_entries_with_defaults_and_units(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + + pm.add_instance("qubit", "q01") + + # every effective path is created with the entry default and unit + assert pm.get("q01.IF") == 5e9 + assert pm.parameter("q01.IF").unit == "Hz" + assert pm.get("q01.octave_gain") == 10 + assert pm.parameter("q01.octave_gain").unit == "dB" + # created through the ordinary add_parameter path: ManagedParameters + # that can carry a Lock, with the full dotted path + assert isinstance(pm.parameter("q01.IF"), ManagedParameter) + assert pm.parameter("q01.IF").path == "parameter_manager.q01.IF" + # after the call the submodule carries the whole shape (D14) + assert pm.instances_of("qubit") == ["q01"] + + +def test_add_instance_keeps_existing_entries_with_value_and_unit(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=6e9, unit="Hz") + + pm.add_instance("qubit", "q01") + + # the existing parameter keeps its own value and unit + assert pm.get("q01.IF") == 6e9 + assert pm.parameter("q01.IF").unit == "Hz" + # the missing one is created + assert pm.get("q01.octave_gain") == 10 + assert pm.instances_of("qubit") == ["q01"] + + +def test_add_instance_accepts_a_dotted_name(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + + pm.add_instance("readout", "q02.ro") + + # the Parameter Groups on the way are created + assert pm.get("q02.ro.IF") == 10e6 + assert pm.parameter("q02.ro.IF").unit == "Hz" + assert pm.instances_of("readout") == ["q02.ro"] + + +def test_add_instance_builds_the_three_tier_case(pm): + put_three_tier_registry(pm) + + pm.add_instance("qubit", "q01") + + # every effective path of the whole nesting chain is created with the + # entry default and unit + assert pm.get("q01.IF") is None + assert pm.parameter("q01.IF").unit == "Hz" + assert pm.get("q01.octave_gain") == 10 + assert pm.parameter("q01.octave_gain").unit == "dB" + assert pm.get("q01.readout.IF") is None + assert pm.parameter("q01.readout.IF").unit == "Hz" + assert pm.get("q01.readout.window") is None + assert pm.parameter("q01.readout.window").unit == "s" + assert pm.get("q01.readout.pw.duration") is None + assert pm.parameter("q01.readout.pw.duration").unit == "s" + # q01 matches at every tier + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("pulse_window") == ["q01.readout.pw"] + + +def test_add_instance_refuses_a_unit_conflict_naming_every_conflicting_path(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=1, unit="V") + pm.add_parameter("q01.octave_gain", initial_value=2, unit="W") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + # every conflicting path with both units, not the first (rule 3) + message = str(excinfo.value) + assert "cannot add an Instance of Type 'qubit' at 'q01'" in message + assert "'q01.IF' carries unit 'V', the Type declares 'Hz'" in message + assert "'q01.octave_gain' carries unit 'W', the Type declares 'dB'" in message + # the scan refuses before anything is created (D14) + assert pm.list() == ["q01.IF", "q01.octave_gain"] + assert pm.instances_of("qubit") == [] + + +def test_add_instance_refuses_a_target_blocked_by_a_parameter(pm): + pm.add_type("sensor") + pm.add_type_parameter("sensor", "sub.x", default=1, unit="V") + pm.add_type_parameter("sensor", "sub.y", default=2, unit="A") + pm.add_parameter("q01.sub", initial_value=0, unit="V") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("sensor", "q01") + + # every offending target path, not the first (rule 3) + message = str(excinfo.value) + assert "cannot create parameter 'q01.sub.x'" in message + assert "cannot create parameter 'q01.sub.y'" in message + assert "'q01.sub' is a parameter, and cannot have child parameters" in message + # nothing was created + assert pm.list() == ["q01.sub"] + + +def test_add_instance_refuses_a_name_blocked_by_a_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + # the root parameter q01 takes the place the Instance needs + pm.add_parameter("q01", initial_value=0, unit="s") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + message = str(excinfo.value) + assert "cannot create parameter 'q01.IF'" in message + assert "'q01' is a parameter, and cannot have child parameters" in message + # a deeper name is blocked by the same parameter + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01.ro") + assert "cannot create parameter 'q01.ro.IF'" in str(excinfo.value) + + assert pm.list() == ["q01"] + + +def test_add_instance_refuses_a_target_blocked_by_a_parameter_group(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + # the Parameter Group q01.IF occupies the target path of the entry IF + pm.add_parameter("q01.IF.sub", unit="s") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + message = str(excinfo.value) + assert "cannot create parameter 'q01.IF'" in message + assert "'q01.IF' is already a Parameter Group" in message + # nothing was created + assert pm.list() == ["q01.IF.sub"] + + +def test_add_instance_refuses_conflicting_creation_targets(pm): + # the Type's effective set holds b and the strict extension b.c + # (buildable while the Type has no Instances): the one call would + # create q01.b and need it as a Parameter Group for q01.b.c + pm.add_type("leaf") + pm.add_type_parameter("leaf", "c", default=1, unit="V") + pm.add_type("branched") + pm.add_type_parameter("branched", "b", default=2, unit="A") + pm.add_nested_type("branched", "b", "leaf") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("branched", "q01") + + message = str(excinfo.value) + assert "cannot create parameter 'q01.b.c'" in message + assert "'q01.b' is also created by this edit" in message + # nothing was created + assert pm.list() == [] + + +def test_add_instance_refuses_the_globals_submodule(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + with pytest.raises( + ValueError, match=re.escape("the Globals submodule name is reserved") + ): + pm.add_instance("qubit", "_globals") + # anything under the Globals submodule is refused too (D18) + with pytest.raises( + ValueError, match=re.escape("the Globals submodule name is reserved") + ): + pm.add_instance("qubit", "_globals.q01") + + # nothing was created + assert pm.list() == [] + + +def test_add_instance_refuses_a_name_with_empty_segments(pm): + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("'' is not a valid submodule path for an Instance"), + ): + pm.add_instance("qubit", "") + with pytest.raises( + ValueError, + match=re.escape("'x..y' is not a valid submodule path for an Instance"), + ): + pm.add_instance("qubit", "x..y") + + assert pm.list() == [] + + +def test_add_instance_with_an_unknown_type_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.add_instance("qubit", "q01") + + assert pm.list() == [] + + +def test_add_instance_on_an_empty_type_creates_nothing(pm): + pm.add_type("empty") + + pm.add_instance("empty", "q01") + + # no parameter and no Parameter Group was created; an empty Type has + # no Instances (D12), so q01 is not listed either + assert pm.list() == [] + assert "q01" not in pm.submodules + assert pm.instances_of("empty") == [] From 97fb5a4c868900a8fa8c847f8bd7705c129d26ab Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 20:29:33 -0500 Subject: [PATCH 045/107] 2.4: fix from review round 1: pin the Globals refusal names on empty and non-empty Types, cover a nested-path unit conflict --- test/pytest/test_pm_types.py | 59 ++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 36fd548..ac3d890 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -1566,6 +1566,24 @@ def test_add_instance_refuses_a_unit_conflict_naming_every_conflicting_path(pm): assert pm.instances_of("qubit") == [] +def test_add_instance_refuses_a_unit_conflict_on_a_nested_effective_path(pm): + # the conflict sits on readout.IF: an effective path of qubit reached + # through the Nested Type, so the scan must walk the expanded set + put_three_tier_registry(pm) + pm.add_parameter("q01.readout.IF", initial_value=1, unit="V") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + # the full dotted path relative to the Parameter Manager, with both units + message = str(excinfo.value) + assert "cannot add an Instance of Type 'qubit' at 'q01'" in message + assert "'q01.readout.IF' carries unit 'V', the Type declares 'Hz'" in message + # the scan refuses before anything is created (D14) + assert pm.list() == ["q01.readout.IF"] + assert pm.instances_of("qubit") == [] + + def test_add_instance_refuses_a_target_blocked_by_a_parameter(pm): pm.add_type("sensor") pm.add_type_parameter("sensor", "sub.x", default=1, unit="V") @@ -1646,12 +1664,21 @@ def test_add_instance_refuses_the_globals_submodule(pm): pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") with pytest.raises( - ValueError, match=re.escape("the Globals submodule name is reserved") + ValueError, + match=re.escape( + "'_globals' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), ): pm.add_instance("qubit", "_globals") - # anything under the Globals submodule is refused too (D18) + # anything under the Globals submodule is refused too, naming its own + # offending name (D18) with pytest.raises( - ValueError, match=re.escape("the Globals submodule name is reserved") + ValueError, + match=re.escape( + "'_globals.q01' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), ): pm.add_instance("qubit", "_globals.q01") @@ -1659,6 +1686,32 @@ def test_add_instance_refuses_the_globals_submodule(pm): assert pm.list() == [] +def test_add_instance_refuses_the_globals_submodule_on_an_empty_type(pm): + # the name refusals are unconditional: even an empty Type, which + # creates nothing anyway, refuses the reserved Globals name (D18) + pm.add_type("empty") + + with pytest.raises( + ValueError, + match=re.escape( + "'_globals' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), + ): + pm.add_instance("empty", "_globals") + with pytest.raises( + ValueError, + match=re.escape( + "'_globals.q01' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), + ): + pm.add_instance("empty", "_globals.q01") + + # nothing was created + assert pm.list() == [] + + def test_add_instance_refuses_a_name_with_empty_segments(pm): pm.add_type("qubit") From b21244564866957ed1628cd3b75c8205a9fb1eab Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 20:36:29 -0500 Subject: [PATCH 046/107] 2.4: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 41 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index b6c5580..79b45f2 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -349,3 +349,44 @@ The root `ParameterManager` now has a Type registry, `self._types`, which maps e - The coder went idle after about 6 minutes of thinking, with no edits and no worker_done. The orchestrator nudged it to continue. - In round 1, all five reviewers still running stopped with "Cannot connect to API" during a short Lumen outage. The orchestrator nudged each of them to resume. - test-reviewer-qwen asked for `/tmp` in round 0. This was rejected, and it was told to use `orchestration/2.3/`. Reviewers used scratch scripts under `orchestration/2.3/` in every round, and each was checked for writes before it ran and deleted afterwards. reviewer-qwen made several read-only `python -c` qcodes lookups, which its role file advises against. + +## 2.4 `add_instance` — 2026-09-24 + +`ParameterManager.add_instance(type_name, name)` writes a Type's effective set into the Parameter Group `name` (D14). `name` is a dotted submodule path relative to the Parameter Manager, so nested Instances such as `q02.ro` work, and the Parameter Groups on the way are created. Everything is validated before anything is created. The Type must exist. An empty name, an empty segment, or `_globals` as the first segment is refused, naming the offending name. The unit-conflict scan runs over every effective path and raises once, naming each existing parameter whose unit differs from the declared one, with both units. Last, `_check_creation_targets` checks for blocked targets. Missing entries are then created with the entry's default and unit through `add_parameter`, so they are `ManagedParameter`s. Existing parameters keep their value and unit. An empty Type creates nothing, not even the submodule, but the name refusals still run first. The method returns `None` and emits no Broadcast (2.5). `test/pytest/test_pm_types.py` grew from 75 to 90 server-free tests. + +### Commit by commit +- `23b42e2` `add_instance` and 13 tests. The orchestrator's five readings in the coder spec covered the name rules, validation before any creation (reusing 2.3's `_check_creation_targets`), creation through `add_parameter`, an empty Type creating nothing, and returning `None`. 2.3 only passed Instance paths that already exist, so the coder reworked `_check_creation_targets` to walk the Instance path itself. A segment that is an existing parameter now blocks every target below it and is named. A missing Parameter Group stops the check, because it will be created on the way and nothing below it can clash. The now-unused `_group_at` was deleted. All six reviewers checked that for 2.3's callers the new walk ends at the same group `_group_at` returned, so 2.3's behaviour is unchanged. The tests cover these cases: + - creation with defaults and units (`ManagedParameter`, full `path`, then listed by `instances_of`) + - keeping an existing entry + - a dotted name + - the three-tier mock matching at all three tiers (`test_add_instance_builds_the_three_tier_case`) + - a unit conflict on two paths + - a target blocked by a parameter, a name blocked by a root parameter, and a target taken by a Parameter Group + - the `b`/`b.c` conflicting targets + - `_globals`, empty segments, an unknown Type and an empty Type + + Nine `add_instance` refusals were added to `test_a_failed_validation_leaves_the_tree_and_registry_byte_identical`. Orchestrator run: ruff clean, 88 in `test_pm_types.py`, 326 in the full suite. +- `97fb5a4` Fix from round 0, test only, two items: + - `test_add_instance_refuses_the_globals_submodule_on_an_empty_type`: nothing pinned that the `_globals` refusal fires before the empty-Type early return. If the `if not effective: return` moved up, `add_instance("empty", "_globals")` would pass silently. Both test reviewers caught it (should-fix), and so did reviewer-glm (nit). Following test-reviewer-glm's nit, both this test and `test_add_instance_refuses_the_globals_submodule` now pin the offending name in the message, not only the reason. + - `test_add_instance_refuses_a_unit_conflict_on_a_nested_effective_path`: the scan had only been tested on top-level paths. The new test puts `q01.readout.IF` at unit `V` against the three-tier `qubit` and expects the full dotted path in the message, with nothing created. test-reviewer-qwen (should-fix) and reviewer-glm (nit) caught it. + + All six approved in re-review, and every raiser confirmed their item fixed. Orchestrator run: ruff clean, 90 in `test_pm_types.py`, 328 in the full suite. + +### Dropped findings +- The docstring says a successful call makes `name` an Instance "unless the Type is empty". That is also false for a name with a non-first `_globals` segment: `add_instance("qubit", "q01._globals")` creates the parameters, but matching skips the name (reviewer-glm, nit) → not sent, left for 3.1. +- Docstring wording: `_check_creation_targets` says `add_instance` "names" missing Parameter Groups, which it does not. `add_instance` says a name "starts with" `_globals` when the code compares the first segment (reviewer-qwen, nits; plan-checker-qwen noted the same) → not sent. Both wordings are still in `params.py`. +- No test calls `add_instance` twice on the same name (test-reviewer-glm, nit) → not sent. +- The empty-segment refusal is not pinned on an empty Type (test-reviewer-glm, round 1 nit). The fix list kept only the `_globals` half of their round-0 item → not sent. They suggest adding `pm.add_instance("empty", "")` next time the file is touched. + +### Questions to Marcos +- The orchestrator flagged its five coder-spec readings for the run report. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- For 3.1: a `_globals` segment after the first is accepted in `add_instance` names, as it already is in 2.3's `add_nested_type`. +- The 2.2 question of whether a parameter with no unit (`None`) carries a declared `""` now reaches the unit-conflict scan too, which compares `existing_unit != entry.unit` exactly. The reviewers disagree on what happens. test-reviewer-glm, test-reviewer-qwen and both plan checkers read the code as treating `None` against `""` as a conflict. reviewer-glm ran a probe and reported that qcodes turns a missing unit into `""`, so the case would not come up. The commits do not settle this and no test pins it. +- No test pins a call that mixes a matching-unit parameter and a conflicting one. reviewer-qwen checked it with a probe. +- Nothing from 2.4 is in `TEST_AUDIT.md`. + +### Process notes +- At the end of implementation the coder tried `rm -rf orchestration/2.4`. It was rejected because the orchestrator's files live there, and the coder was told to leave the folder. +- Reviewers checked their findings with scratch scripts under `orchestration/2.4/`. Each was scanned before it ran and deleted afterwards. plan-checker-glm ran the full suite detached to a log file in both rounds, because piping it hangs (see 2.1). Neither log is left in the folder. There were no stalls and no nudges. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 37ce543..3669b16 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -492,7 +492,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session validate-then-mutate. Tests: `test_pm_types.py` — each edit's effect table from D13, plus a failing validation leaving the tree byte-identical (compare `list()` and values before and after). -- [ ] **2.4 `add_instance`.** Per D14, including the up-front unit-conflict scan that raises +- [x] **2.4 `add_instance`.** Per D14, including the up-front unit-conflict scan that raises listing every conflicting path before creating anything, dotted (nested) names, and `_globals` refusal. Tests: `test_pm_types.py`. - [ ] **2.5 `pm-type-update` and side-effect broadcasts.** Every Type-editing method emits From 9de2239a58e8d041ee31bf5f4cf43ea2bd850dba Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 20:44:44 -0500 Subject: [PATCH 047/107] 2.5: pm-type-update and side-effect parameter-creation broadcasts from the Type API, with sink and proxy tests --- src/instrumentserver/params.py | 178 ++++++++++- test/pytest/test_pm_types.py | 550 ++++++++++++++++++++++++++++++++- 2 files changed, 709 insertions(+), 19 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index d8485c2..c1600cf 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -15,7 +15,9 @@ from . import serialize from .base import Broadcaster from .blueprints import ( + PARAMETER_CREATION, PM_LOCK_UPDATE, + PM_TYPE_UPDATE, ParameterBroadcastBluePrint, PMLockBluePrint, PMTypeBluePrint, @@ -416,7 +418,12 @@ class ParameterManager(Broadcaster, ParameterGroup): Station. Every Lock method that changes a Lock emits one ``pm-lock-update`` Broadcast per affected Follower (D10), and ``remove_parameter`` emits them for the Locks that deleting a Target - drops; Type editing will emit through it too. + drops. Every Type-editing method emits ``pm-type-update`` with the + edited Type's fresh ``PMTypeBluePrint`` (D22), and the parameters the + Type edits and :meth:`add_instance` create as side effects are + re-emitted as ``parameter-creation`` Broadcasts (ADR-0003); direct + ``add_parameter`` calls keep being announced by the Server, so nothing + is announced twice. For the parameter manager to recognize other profiles in disk, the profile filename needs to start with 'parameter_manager-' @@ -549,6 +556,40 @@ def _broadcast_lock_update( ) ) + def _broadcast_type_update(self, type_name: str) -> None: + """Emit one ``pm-type-update`` Broadcast about the Type ``type_name`` + (D22): ``name`` is the Type's full dotted name and the payload is + its fresh :class:`PMTypeBluePrint`, so a GUI can replace that one + Type locally with no follow-up fetch. With no sink registered, + :meth:`broadcast` is a no-op, so standalone use of the Parameter + Manager emits nothing.""" + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{type_name}", + action=PM_TYPE_UPDATE, + value=self.get_type(type_name), + ) + ) + + def _broadcast_parameter_creation( + self, parameter_path: str, value: Any, unit: str + ) -> None: + """Re-emit one ``parameter-creation`` Broadcast for a parameter this + Parameter Manager created as a side effect of a Type edit or of + :meth:`add_instance` (D22, ADR-0003), in the same shape the Server + emits for a direct ``add_parameter`` call: the full dotted path as + ``name``, the initial value as ``value`` and the unit as ``unit``. + Direct ``add_parameter`` calls are announced by the Server and emit + nothing here, so nothing is announced twice.""" + self.broadcast( + ParameterBroadcastBluePrint( + name=self._full_path(parameter_path), + action=PARAMETER_CREATION, + value=value, + unit=unit, + ) + ) + def _full_path(self, relative_name: str) -> str: """The full dotted path (with the instrument name) of a path relative to this Parameter Manager.""" @@ -789,9 +830,19 @@ def followers_of(self, name: str) -> "List[str]": # unit; its Nested Types map the submodule name that requires them to # the nested Type's name. Type names are refused for the reserved # Globals submodule ``_globals``. Methods validate before mutating and - # name every offending path or Type in an error. No Type method emits - # a Broadcast yet: the ``pm-type-update`` emissions arrive with the - # Type-editing broadcasts task. + # name every offending path or Type in an error. + # + # Every Type-editing method emits its Broadcasts only after the whole + # mutation succeeded (D22): one ``parameter-creation`` Broadcast per + # parameter the edit created as a side effect, in creation order, + # followed by one ``pm-type-update`` Broadcast per affected Type — the + # edited Type first, then every Type nesting it, whose effective + # parameter set the edit changed too — carrying that Type's fresh + # ``PMTypeBluePrint``; ``remove_type`` emits exactly one + # ``pm-type-update`` with a ``None`` payload. ``set_type_parameter_default`` + # changes no effective set, so only the edited Type is named. The + # read-only queries (``list_types``, ``get_type``, ``instances_of``, + # ``types_of``) and failed validations emit nothing. # ------------------------------------------------------------------ def _require_type(self, name: str) -> "_TypeDefinition": @@ -809,6 +860,9 @@ def add_type(self, name: str) -> None: Globals name ``_globals`` or when a Type with that name exists already; nothing is changed then. A fresh Type has no entries and no Nested Types, so it has no Instances until entries are added. + Emits one ``pm-type-update`` Broadcast carrying the new Type's + :class:`PMTypeBluePrint` after it is created (D22); a failed + validation emits nothing. :param name: Name of the Type. """ @@ -822,6 +876,7 @@ def add_type(self, name: str) -> None: if name in self._types: raise ValueError(f"a Type named '{name}' already exists") self._types[name] = _TypeDefinition(name=name) + self._broadcast_type_update(name) def remove_type(self, name: str) -> None: """Remove the Type ``name`` from the Type registry. @@ -829,7 +884,10 @@ def remove_type(self, name: str) -> None: The parameters of Instances are untouched (D13). Raises ``ValueError`` when no such Type exists, and — naming every Type that nests it — while any other Type still requires ``name`` as a - Nested Type; nothing is removed then. + Nested Type; nothing is removed then. Emits exactly one + ``pm-type-update`` Broadcast with a ``None`` payload after the + Type is removed (D22): nobody nests it, so no other Type is + affected; a failed validation emits nothing. :param name: Name of the Type. """ @@ -845,6 +903,11 @@ def remove_type(self, name: str) -> None: f"cannot remove Type '{name}': nested in Type(s) {nesters}" ) del self._types[name] + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{name}", action=PM_TYPE_UPDATE, value=None + ) + ) def list_types(self) -> List[str]: """Names of every Type in the Type registry.""" @@ -1125,9 +1188,15 @@ def types_of(self, path: str) -> List[str]: # ordinary ``add_parameter`` path, as a ``ManagedParameter`` with the # entry's default value and unit. A parameter that already exists at # a target path is left alone: the submodule it lives in simply stops - # being an Instance when its unit differs (D1). No edit emits a - # Broadcast yet: ``pm-type-update`` and the re-emitted - # ``parameter-creation`` arrive with the Type-editing broadcasts task. + # being an Instance when its unit differs (D1). + # + # The Broadcasts go out only after the whole edit succeeded (D22): + # one ``parameter-creation`` per created parameter, in creation + # order, then one ``pm-type-update`` per affected Type — the edited + # Type first, then every Type nesting it, whose effective parameter + # set the edit changed too. The affected Types are the keys of + # ``_nesting_prefixes``, which the side-effect computation already + # walks. A refused edit emits nothing. # ------------------------------------------------------------------ def _nesting_prefixes(self, type_name: str) -> Dict[str, List[str]]: @@ -1312,6 +1381,13 @@ def add_type_parameter( is an existing Parameter Group, or another target of the same edit is a strict segment-prefix of it. + After the edit succeeds it emits one ``parameter-creation`` + Broadcast per parameter it created, in creation order, followed + by one ``pm-type-update`` Broadcast per affected Type — the + edited Type first, then every Type nesting it, whose effective + parameter set the new entry extends (D22, ADR-0003); a failed + validation emits nothing. + :param type_name: Name of the Type. :param path: Relative parameter path of the entry. :param default: Default value the created parameters start with. @@ -1366,6 +1442,7 @@ def add_type_parameter( self._check_creation_targets(targets) definition.parameters[path] = _TypeEntry(default=default, unit=unit) created: set = set() + creations: List[Tuple[str, Any, str]] = [] for instance_path, relative_target in targets: full = f"{instance_path}.{relative_target}" if full in created: @@ -1373,6 +1450,16 @@ def add_type_parameter( created.add(full) if not self.has_param(full): self.add_parameter(full, initial_value=default, unit=unit) + creations.append((full, default, unit)) + # broadcasts after the whole edit succeeded (D22): one + # parameter-creation per created parameter in creation order, + # then one pm-type-update per affected Type, the edited Type first + for created_path, initial_value, created_unit in creations: + self._broadcast_parameter_creation( + created_path, initial_value, created_unit + ) + for name in affected: + self._broadcast_type_update(name) def remove_type_parameter(self, type_name: str, path: str) -> None: """Remove the entry at ``path`` from the Type ``type_name``'s own @@ -1384,13 +1471,20 @@ def remove_type_parameter(self, type_name: str, path: str) -> None: when ``path`` is not an entry of the Type itself — naming the Type that defines it, when the path only reaches the effective parameter set through a Nested Type — and when it is in no - effective set at all. Nothing is removed then. + effective set at all. Nothing is removed then. Emits one + ``pm-type-update`` Broadcast per affected Type — the edited Type + first, then every Type nesting it, whose effective parameter set + loses the path — after the entry is removed (D22); a failed + validation emits nothing. :param type_name: Name of the Type. :param path: Relative parameter path of the entry. """ self._require_type_entry(type_name, path) + affected = self._nesting_prefixes(type_name) del self._types[type_name].parameters[path] + for name in affected: + self._broadcast_type_update(name) def set_type_parameter_default( self, type_name: str, path: str, value: Any @@ -1401,7 +1495,12 @@ def set_type_parameter_default( new default. Raises ``ValueError`` naming the path under the same conditions - as :meth:`remove_type_parameter`; nothing is changed then. + as :meth:`remove_type_parameter`; nothing is changed then. Emits + one ``pm-type-update`` Broadcast carrying the edited Type's fresh + blueprint after the default is set (D22); no Broadcast names a + nesting Type, since a Type's effective parameter set carries + units and defining Types, not defaults, so their blueprints are + unchanged. A failed validation emits nothing. :param type_name: Name of the Type. :param path: Relative parameter path of the entry. @@ -1409,6 +1508,7 @@ def set_type_parameter_default( """ entry = self._require_type_entry(type_name, path) entry.default = value + self._broadcast_type_update(type_name) def set_type_parameter_unit( self, type_name: str, path: str, unit: str @@ -1421,7 +1521,11 @@ def set_type_parameter_unit( parameter already carrying the new unit is simply set again. Raises ``ValueError`` naming the path under the same conditions - as :meth:`remove_type_parameter`; nothing is changed then. + as :meth:`remove_type_parameter`; nothing is changed then. Emits + one ``pm-type-update`` Broadcast per affected Type — the edited + Type first, then every Type nesting it, whose effective parameter + set carries the changed unit — after the unit is set and + propagated (D22); a failed validation emits nothing. :param type_name: Name of the Type. :param path: Relative parameter path of the entry. @@ -1443,6 +1547,8 @@ def set_type_parameter_unit( propagated.add(full) if self.has_param(full): self.parameter(full).unit = unit + for name in affected: + self._broadcast_type_update(name) def add_nested_type( self, type_name: str, submodule: str, nested_type: str @@ -1466,6 +1572,13 @@ def add_nested_type( parameter, the final segment is an existing Parameter Group, or another target of the same edit is a strict segment-prefix of it. + After the edit succeeds it emits one ``parameter-creation`` + Broadcast per parameter it created, in creation order, followed + by one ``pm-type-update`` Broadcast per affected Type — the + edited Type first, then every Type nesting it, whose effective + parameter set the nested entries extend (D22, ADR-0003); a failed + validation emits nothing. + :param type_name: Name of the outer Type. :param submodule: Name of the submodule that requires the Nested Type. @@ -1552,6 +1665,7 @@ def add_nested_type( ) definition.nested[submodule] = nested_type created: set = set() + creations: List[Tuple[str, Any, str]] = [] for instance_path, relative_target, entry in targets: full = f"{instance_path}.{relative_target}" if full in created: @@ -1561,6 +1675,16 @@ def add_nested_type( self.add_parameter( full, initial_value=entry.default, unit=entry.unit ) + creations.append((full, entry.default, entry.unit)) + # broadcasts after the whole edit succeeded (D22): one + # parameter-creation per created parameter in creation order, + # then one pm-type-update per affected Type, the edited Type first + for created_path, initial_value, created_unit in creations: + self._broadcast_parameter_creation( + created_path, initial_value, created_unit + ) + for name in affected: + self._broadcast_type_update(name) def remove_nested_type(self, type_name: str, submodule: str) -> None: """Remove the Nested Type required at the submodule ``submodule`` @@ -1570,7 +1694,11 @@ def remove_nested_type(self, type_name: str, submodule: str) -> None: Raises ``ValueError`` naming the Type and the submodule when no such Type exists or the submodule requires no Nested Type; - nothing is removed then. + nothing is removed then. Emits one ``pm-type-update`` Broadcast + per affected Type — the edited Type first, then every Type nesting + it, whose effective parameter set loses the nested paths — after + the Nested Type is removed (D22); a failed validation emits + nothing. :param type_name: Name of the Type. :param submodule: Name of the submodule that requires the Nested @@ -1582,7 +1710,10 @@ def remove_nested_type(self, type_name: str, submodule: str) -> None: f"submodule '{submodule}' of Type '{type_name}' has no " "Nested Type" ) + affected = self._nesting_prefixes(type_name) del definition.nested[submodule] + for name in affected: + self._broadcast_type_update(name) # ------------------------------------------------------------------ # Instances (plan decision D14) @@ -1592,10 +1723,12 @@ def remove_nested_type(self, type_name: str, submodule: str) -> None: # It validates everything first: the Type, the name, a unit conflict # on any existing parameter, and the creation targets through # ``_check_creation_targets``; on an error nothing is created. An - # empty Type creates nothing and has no Instances (D12). Like the - # Type edits, it emits no Broadcast yet: the ``pm-type-update`` and - # re-emitted ``parameter-creation`` arrive with the Type-editing - # broadcasts task. + # empty Type creates nothing and has no Instances (D12). + # + # After the whole call succeeded it emits one ``parameter-creation`` + # Broadcast per parameter it created, in creation order (D22, + # ADR-0003); it edits no Type, so it emits no ``pm-type-update``. A + # refused call emits nothing. # ------------------------------------------------------------------ def add_instance(self, type_name: str, name: str) -> None: @@ -1624,6 +1757,11 @@ def add_instance(self, type_name: str, name: str) -> None: existing parameter, or the final segment of a target is an existing Parameter Group. + After the Instance is created it emits one ``parameter-creation`` + Broadcast per parameter it created, in creation order (D22, + ADR-0003); the call edits no Type, so it emits no + ``pm-type-update``. A failed validation emits nothing. + :param type_name: Name of the Type. :param name: Dotted submodule path of the Instance, relative to this Parameter Manager. @@ -1664,12 +1802,20 @@ def add_instance(self, type_name: str, name: str) -> None: f"'{name}': " + "; ".join(conflicts) ) self._check_creation_targets([(name, path) for path in effective]) + creations: List[Tuple[str, Any, str]] = [] for path, entry in effective.items(): full = f"{name}.{path}" if not self.has_param(full): self.add_parameter( full, initial_value=entry.default, unit=entry.unit ) + creations.append((full, entry.default, entry.unit)) + # one parameter-creation per created parameter, in creation order + # (D22); no pm-type-update: the call edits no Type + for created_path, initial_value, created_unit in creations: + self._broadcast_parameter_creation( + created_path, initial_value, created_unit + ) @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index ac3d890..917906c 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -1,7 +1,8 @@ """Tests for the Type registry and definitions (plan task 2.1), the duck-typed Instance matching (plan task 2.2), the Type edits with -Instance side effects (plan task 2.3) and ``add_instance`` (plan task -2.4). +Instance side effects (plan task 2.3), ``add_instance`` (plan task 2.4) +and the ``pm-type-update`` and side-effect creation Broadcasts (plan task +2.5). The definition and editing methods are exercised through the public API: ``add_type`` / ``add_type_parameter`` / ``add_nested_type`` and friends. @@ -22,6 +23,18 @@ three-tier Type of the mock, the up-front unit-conflict scan, blocked targets, the Globals refusal, unknown and empty Types) — every refusal leaving the registry and the parameter tree byte-identical. +The Broadcast part checks, on a local Parameter Manager with a sink, that +every Type-editing method emits ``pm-type-update`` (one per affected +Type, the edited one first; ``None`` on ``remove_type``) after the +mutation, that the parameters created as side effects are re-emitted as +``parameter-creation`` in creation order before the Type updates, and +that read-only queries and refused calls emit nothing. The last part +exercises the Type API through a client proxy against a live Server: +every method callable over the wire, ``get_type``/``list_types`` +deserialising, and a SubClient receiving ``pm-type-update`` and the +per-parameter ``parameter-creation`` Broadcasts of an ``add_instance`` +issued from a second client, whose creations the first client's proxy +shows after ``update()``. """ import copy @@ -29,7 +42,14 @@ import pytest -from instrumentserver.blueprints import PMTypeBluePrint, deserialize_obj +from instrumentserver.blueprints import ( + PARAMETER_CREATION, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMTypeBluePrint, + deserialize_obj, +) +from instrumentserver.client.proxy import Client from instrumentserver.params import ( ManagedParameter, ParameterManager, @@ -1746,3 +1766,527 @@ def test_add_instance_on_an_empty_type_creates_nothing(pm): assert pm.list() == [] assert "q01" not in pm.submodules assert pm.instances_of("empty") == [] + + +# --------------------------------------------------------------------------- +# pm-type-update and side-effect creation Broadcasts (plan task 2.5, D22) +# +# One pm-type-update per affected Type — the edited Type first, then every +# Type nesting it, whose effective parameter set the edit changed — +# carrying that Type's fresh PMTypeBluePrint; remove_type emits exactly +# one with a None payload. The parameters a Type edit or add_instance +# creates as side effects are re-emitted as one parameter-creation each, +# in creation order, before the Type updates. Read-only queries, refused +# calls and kept parameters emit nothing. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def pm_with_sink(pm): + """The Type API fixture with a Broadcast sink attached, recording + every Broadcast the Parameter Manager emits.""" + received = [] + pm.add_broadcast_sink(received.append) + return pm, received + + +def put_nested_instance(pm): + """``readout`` (entry IF) nested in ``qubit``, with the Instance q01 + carrying the whole shape: an edit to readout reaches qubit's effective + parameter set too, so both Types are affected.""" + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + +def test_add_type_emits_one_pm_type_update_with_the_fresh_blueprint(pm_with_sink): + pm, received = pm_with_sink + + pm.add_type("qubit") + + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.qubit" + assert bp.action == PM_TYPE_UPDATE + assert isinstance(bp.value, PMTypeBluePrint) + assert bp.value == PMTypeBluePrint( + name="qubit", parameters={}, nested={}, effective={} + ) + + +def test_remove_type_emits_one_none_payload(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("readout") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + received.clear() + + pm.remove_type("qubit") + + # qubit nests readout, not the other way round: removing it affects no + # other Type, so exactly one Broadcast with a None payload goes out + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.qubit" + assert bp.action == PM_TYPE_UPDATE + assert bp.value is None + + +def test_add_type_parameter_emits_the_creation_then_the_type_updates(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + # the side-effect creation first, then one pm-type-update per affected + # Type: the edited readout first, then qubit, whose effective set the + # new entry extends + assert len(received) == 3 + creation, readout_update, qubit_update = received + assert creation.name == "parameter_manager.q01.readout.window" + assert creation.action == PARAMETER_CREATION + assert creation.value == 2e-6 + assert creation.unit == "s" + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert isinstance(readout_update.value, PMTypeBluePrint) + assert readout_update.value.parameters["window"] == { + "default": 2e-6, + "unit": "s", + "target": None, + } + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert qubit_update.value.effective["readout.window"] == { + "unit": "s", + "from_type": "readout", + } + + +def test_remove_type_parameter_emits_updates_for_the_edited_and_nesting_types( + pm_with_sink, +): + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + received.clear() + + pm.remove_type_parameter("readout", "window") + + # no parameter is removed (D13), so no creations: one pm-type-update + # per affected Type, the edited readout first + assert len(received) == 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert "window" not in readout_update.value.parameters + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert "readout.window" not in qubit_update.value.effective + + +def test_set_type_parameter_default_emits_one_update_for_the_edited_type( + pm_with_sink, +): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + pm.set_type_parameter_default("readout", "IF", 20e6) + + # only the edited Type is named: a nesting Type's blueprint is + # unchanged, since the effective parameter set carries units and + # defining Types, not defaults + assert len(received) == 1 + update = received[0] + assert update.name == "parameter_manager.readout" + assert update.action == PM_TYPE_UPDATE + assert update.value.parameters["IF"]["default"] == 20e6 + + +def test_set_type_parameter_unit_emits_updates_for_every_affected_type(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + pm.set_type_parameter_unit("readout", "IF", "V") + + # the unit reaches every Instance and the effective parameter set of + # every Type nesting the edited one, so both Types are named + assert len(received) == 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert readout_update.value.effective["IF"]["unit"] == "V" + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.value.effective["readout.IF"]["unit"] == "V" + # the propagation itself happened (D13) + assert pm.parameter("q01.readout.IF").unit == "V" + + +def test_add_nested_type_emits_the_creation_then_the_type_updates(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + received.clear() + + pm.add_nested_type("readout", "pw", "pulse_window") + + assert len(received) == 3 + creation, readout_update, qubit_update = received + assert creation.name == "parameter_manager.q01.readout.pw.duration" + assert creation.action == PARAMETER_CREATION + assert creation.value is None + assert creation.unit == "s" + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert readout_update.value.nested == {"pw": "pulse_window"} + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert qubit_update.value.effective["readout.pw.duration"] == { + "unit": "s", + "from_type": "pulse_window", + } + + +def test_remove_nested_type_emits_updates_for_the_edited_and_nesting_types( + pm_with_sink, +): + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_nested_type("readout", "pw", "pulse_window") + received.clear() + + pm.remove_nested_type("readout", "pw") + + assert len(received) == 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert readout_update.value.nested == {} + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert "readout.pw.duration" not in qubit_update.value.effective + + +def test_add_instance_emits_one_creation_per_created_parameter(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + received.clear() + + pm.add_instance("qubit", "q01") + + # one parameter-creation per created parameter, in creation order; + # add_instance edits no Type, so no pm-type-update goes out (D22) + assert len(received) == 2 + first, second = received + assert first.name == "parameter_manager.q01.IF" + assert first.action == PARAMETER_CREATION + assert first.value == 5e9 + assert first.unit == "Hz" + assert second.name == "parameter_manager.q01.octave_gain" + assert second.action == PARAMETER_CREATION + assert second.value == 10 + assert second.unit == "dB" + + +def test_add_instance_emits_nothing_for_kept_parameters(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=6e9, unit="Hz") + received.clear() + + pm.add_instance("qubit", "q01") + + # the kept q01.IF emits nothing; only the created octave_gain does + assert len(received) == 1 + assert received[0].name == "parameter_manager.q01.octave_gain" + assert received[0].action == PARAMETER_CREATION + + +def test_add_instance_of_an_empty_type_emits_nothing(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("empty") + received.clear() + + pm.add_instance("empty", "q01") + + # an empty Type creates nothing, so nothing is broadcast + assert received == [] + assert pm.list() == [] + + +def test_read_only_type_queries_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + assert pm.list_types() == ["readout", "qubit"] + assert isinstance(pm.get_type("qubit"), PMTypeBluePrint) + assert pm.instances_of("qubit") == ["q01"] + assert pm.types_of("q01.octave_gain") == ["qubit"] + + assert received == [] + + +def test_failed_type_validations_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + # definitions + with pytest.raises(ValueError): + pm.add_type("_globals") + with pytest.raises(ValueError): + pm.add_type("readout") # duplicate name + with pytest.raises(ValueError): + pm.remove_type("nope") + with pytest.raises(ValueError): + pm.remove_type("readout") # nested in qubit + # edits + with pytest.raises(ValueError): + pm.add_type_parameter("readout", "IF") # already in the effective set + with pytest.raises(ValueError): + pm.add_type_parameter("nope", "x") + with pytest.raises(ValueError): + pm.remove_type_parameter("readout", "nope") + with pytest.raises(ValueError): + pm.set_type_parameter_default("readout", "nope", 1) + with pytest.raises(ValueError): + pm.set_type_parameter_unit("readout", "nope", "V") + # nesting + with pytest.raises(ValueError): + pm.add_nested_type("readout", "self", "readout") + with pytest.raises(ValueError): + pm.add_nested_type("qubit", "readout", "readout") # occupied submodule + with pytest.raises(ValueError): + pm.add_nested_type("qubit", "_globals", "readout") + with pytest.raises(ValueError): + pm.remove_nested_type("readout", "pw") # no Nested Type there + # instances + with pytest.raises(ValueError): + pm.add_instance("nope", "q09") + with pytest.raises(ValueError): + pm.add_instance("qubit", "_globals") + with pytest.raises(ValueError): + pm.add_instance("qubit", "") + pm.add_parameter("q09.octave_gain", initial_value=1, unit="V") + with pytest.raises(ValueError): + pm.add_instance("qubit", "q09") # unit conflict on q09.octave_gain + pm.add_parameter("q10.octave_gain.sub", initial_value=0, unit="s") + with pytest.raises(ValueError): + pm.add_instance("qubit", "q10") # target q10.octave_gain is a Parameter Group + + assert received == [] + + +def test_type_broadcast_payloads_are_snapshots_of_their_time(pm_with_sink): + # every pm-type-update carries a blueprint built at emit time: a sink + # that keeps payloads must not see an earlier Type grow when entries + # are added later + pm, received = pm_with_sink + + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + + assert len(received) == 3 + first, second, third = (bp.value for bp in received) + assert isinstance(first, PMTypeBluePrint) + assert first.parameters == {} + assert list(second.parameters) == ["IF"] + assert list(third.parameters) == ["IF", "octave_gain"] + + +# --------------------------------------------------------------------------- +# Type API and Broadcasts through a client proxy against a live Server +# (plan task 2.5) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3), so every Type-editing method call over the wire also emits +# its Broadcasts on the PUB socket. The server-side Parameter Manager is +# shared by all tests of this module, so every test removes the parameters +# and Types it created again. +# --------------------------------------------------------------------------- + +PROXY_TYPE = "ptype_qubit" +PROXY_NESTED_TYPE = "ptype_readout" +PROXY_INSTANCE = "ptype_q01" + + +def _cleanup_proxy_types(params): + """Remove every parameter and Type the proxy tests create, so the + module's shared server-side Parameter Manager starts each test clean. + ``remove_type`` refuses while a Type nests another, so the nested map + is emptied and the outer Type is removed first.""" + for path in list(params.list()): + if path.split(".")[0].startswith("ptype_"): + params.remove_parameter(path) + for name in (PROXY_TYPE, PROXY_NESTED_TYPE): + if name in params.list_types(): + bp = params.get_type(name) + for submodule in list(bp.nested): + params.remove_nested_type(name, submodule) + params.remove_type(name) + + +def test_every_type_method_is_callable_through_the_proxy(param_manager): + cli, params = param_manager + _cleanup_proxy_types(params) + try: + params.add_type(PROXY_NESTED_TYPE) + params.add_type_parameter(PROXY_NESTED_TYPE, "IF", default=10e6, unit="Hz") + params.add_type(PROXY_TYPE) + params.add_type_parameter(PROXY_TYPE, "octave_gain", default=10, unit="dB") + params.add_nested_type(PROXY_TYPE, "ro", PROXY_NESTED_TYPE) + + assert sorted(params.list_types()) == [PROXY_TYPE, PROXY_NESTED_TYPE] + + params.set_type_parameter_default(PROXY_NESTED_TYPE, "IF", 20e6) + params.set_type_parameter_unit(PROXY_NESTED_TYPE, "IF", "V") + + params.add_instance(PROXY_NESTED_TYPE, PROXY_INSTANCE) + assert params.instances_of(PROXY_NESTED_TYPE) == [PROXY_INSTANCE] + assert params.types_of(f"{PROXY_INSTANCE}.IF") == [PROXY_NESTED_TYPE] + # the proxy method call does not refresh the proxy itself: after + # update() the created Instance shows up with the entry default + # and the propagated unit + params.update() + assert params.ptype_q01.IF() == 20e6 + assert params.ptype_q01.IF.unit == "V" + + # the removals work over the wire too + params.remove_type_parameter(PROXY_NESTED_TYPE, "IF") + assert params.get_type(PROXY_NESTED_TYPE).parameters == {} + params.remove_nested_type(PROXY_TYPE, "ro") + assert params.get_type(PROXY_TYPE).nested == {} + params.remove_type(PROXY_TYPE) + params.remove_type(PROXY_NESTED_TYPE) + assert params.list_types() == [] + finally: + _cleanup_proxy_types(params) + + +def test_get_type_and_list_types_deserialise_over_the_wire(param_manager): + cli, params = param_manager + _cleanup_proxy_types(params) + try: + params.add_type(PROXY_NESTED_TYPE) + params.add_type_parameter(PROXY_NESTED_TYPE, "IF", default=10e6, unit="Hz") + params.add_type(PROXY_TYPE) + params.add_type_parameter(PROXY_TYPE, "octave_gain", default=10, unit="dB") + params.add_nested_type(PROXY_TYPE, "ro", PROXY_NESTED_TYPE) + + types = params.list_types() + assert isinstance(types, list) + assert sorted(types) == [PROXY_TYPE, PROXY_NESTED_TYPE] + + bp = params.get_type(PROXY_TYPE) + assert isinstance(bp, PMTypeBluePrint) + assert bp.name == PROXY_TYPE + assert bp.parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + } + assert bp.nested == {"ro": PROXY_NESTED_TYPE} + assert bp.effective == { + "octave_gain": {"unit": "dB", "from_type": PROXY_TYPE}, + "ro.IF": {"unit": "Hz", "from_type": PROXY_NESTED_TYPE}, + } + + nested_bp = params.get_type(PROXY_NESTED_TYPE) + assert isinstance(nested_bp, PMTypeBluePrint) + assert nested_bp.parameters == { + "IF": {"default": 10e6, "unit": "Hz", "target": None}, + } + finally: + _cleanup_proxy_types(params) + + +def test_subclient_sees_pm_type_update_and_creations_from_a_second_client( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _cleanup_proxy_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + # a Type edit from the second client: the SubClient sees the + # pm-type-update with the fresh blueprint + second_params.add_type(PROXY_NESTED_TYPE) + wait_for_broadcasts(received) + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == f"parameter_manager.{PROXY_NESTED_TYPE}" + assert bp.action == PM_TYPE_UPDATE + assert isinstance(bp.value, PMTypeBluePrint) + assert bp.value.parameters == {} + + second_params.add_type_parameter( + PROXY_NESTED_TYPE, "IF", default=5, unit="Hz" + ) + wait_for_broadcasts(received, n=2) + assert len(received) == 2 + assert received[1].action == PM_TYPE_UPDATE + assert received[1].value.parameters == { + "IF": {"default": 5, "unit": "Hz", "target": None}, + } + received.clear() + + # add_instance from the second client: exactly one + # parameter-creation per created parameter, and no + # pm-type-update, since the call edits no Type + second_params.add_instance(PROXY_NESTED_TYPE, PROXY_INSTANCE) + wait_for_broadcasts(received) + assert len(received) == 1 + creation = received[0] + assert creation.action == PARAMETER_CREATION + assert creation.name == f"parameter_manager.{PROXY_INSTANCE}.IF" + assert creation.value == 5 + assert creation.unit == "Hz" + finally: + second_cli.disconnect() + _cleanup_proxy_types(params) + + +def test_the_first_clients_proxy_shows_the_created_parameters_after_update( + param_manager, server_port +): + cli, params = param_manager + _cleanup_proxy_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + # the second client adds the Type, one entry and an Instance + second_params.add_type(PROXY_TYPE) + second_params.add_type_parameter(PROXY_TYPE, "IF", default=5, unit="Hz") + second_params.add_instance(PROXY_TYPE, PROXY_INSTANCE) + + # the first client's proxy predates the creations + assert PROXY_INSTANCE not in params.submodules + + # update() invalidates the cached blueprint and rebuilds the proxy + params.update() + assert PROXY_INSTANCE in params.submodules + assert params.ptype_q01.IF() == 5 + assert params.ptype_q01.IF.unit == "Hz" + finally: + second_cli.disconnect() + _cleanup_proxy_types(params) From 80635c35b580805aa5b49c6dd56cb135362d89a7 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 21:01:16 -0500 Subject: [PATCH 048/107] 2.5: fix from review round 1: three-tier pm-type-update chain test and kept-parameter no-creation tests for the edit loops --- test/pytest/test_pm_types.py | 81 ++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 917906c..5850eac 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -1869,6 +1869,66 @@ def test_add_type_parameter_emits_the_creation_then_the_type_updates(pm_with_sin } +def test_pm_type_update_reaches_every_type_of_a_three_tier_nesting_chain( + pm_with_sink, +): + # the walk must follow the nesting chain transitively: pulse_window is + # nested in readout, which is nested in qubit, so an edit to + # pulse_window affects all three Types — a walk stopping at the direct + # nesters would miss qubit + pm, received = pm_with_sink + put_three_tier_registry(pm) + received.clear() + + pm.add_type_parameter("pulse_window", "amp", default=1, unit="V") + + # no Instances exist, so no parameter-creation goes out: exactly one + # pm-type-update per affected Type, the edited pulse_window first, + # then outwards along the chain + assert len(received) == 3 + pulse_update, readout_update, qubit_update = received + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] * 3 + assert isinstance(pulse_update.value, PMTypeBluePrint) + assert pulse_update.name == "parameter_manager.pulse_window" + assert pulse_update.value.parameters["amp"] == { + "default": 1, + "unit": "V", + "target": None, + } + assert isinstance(readout_update.value, PMTypeBluePrint) + assert readout_update.name == "parameter_manager.readout" + assert readout_update.value.effective["pw.amp"] == { + "unit": "V", + "from_type": "pulse_window", + } + assert isinstance(qubit_update.value, PMTypeBluePrint) + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.value.effective["readout.pw.amp"] == { + "unit": "V", + "from_type": "pulse_window", + } + + +def test_add_type_parameter_emits_no_creation_for_kept_parameters(pm_with_sink): + # q01.readout.window exists already: the edit writes the entry into the + # registry only, and the kept parameter emits no parameter-creation + # (D22); readout's own Instance target and qubit's prefixed target are + # the same path, so nothing is created at all + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_parameter("q01.readout.window", initial_value=3e-6, unit="s") + received.clear() + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] * 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert qubit_update.name == "parameter_manager.qubit" + # the kept parameter kept its own value (D13) + assert pm.get("q01.readout.window") == 3e-6 + + def test_remove_type_parameter_emits_updates_for_the_edited_and_nesting_types( pm_with_sink, ): @@ -1955,6 +2015,27 @@ def test_add_nested_type_emits_the_creation_then_the_type_updates(pm_with_sink): } +def test_add_nested_type_emits_no_creation_for_kept_parameters(pm_with_sink): + # the nested entry's parameter exists already at the target path: the + # edit writes the Nested Type into the registry only, and the kept + # parameter emits no parameter-creation (D22) + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_parameter("q01.readout.pw.duration", initial_value=500e-9, unit="s") + received.clear() + + pm.add_nested_type("readout", "pw", "pulse_window") + + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] * 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert qubit_update.name == "parameter_manager.qubit" + # the kept parameter kept its own value (D13) + assert pm.get("q01.readout.pw.duration") == 500e-9 + + def test_remove_nested_type_emits_updates_for_the_edited_and_nesting_types( pm_with_sink, ): From d8960ac05db33d7cc0b8759198326956265fa57d Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 21:08:55 -0500 Subject: [PATCH 049/107] 2.5: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 51 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 79b45f2..e49825e 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -390,3 +390,54 @@ The root `ParameterManager` now has a Type registry, `self._types`, which maps e ### Process notes - At the end of implementation the coder tried `rm -rf orchestration/2.4`. It was rejected because the orchestrator's files live there, and the coder was told to leave the folder. - Reviewers checked their findings with scratch scripts under `orchestration/2.4/`. Each was scanned before it ran and deleted afterwards. plan-checker-glm ran the full suite detached to a log file in both rounds, because piping it hangs (see 2.1). Neither log is left in the folder. There were no stalls and no nudges. + +## 2.5 `pm-type-update` and side-effect broadcasts — 2026-09-24 + +The Type API in `src/instrumentserver/params.py` now emits its own Broadcasts (D22, ADR-0003). All eight Type-editing methods (`add_type`, `remove_type` and the six 2.3 edits) emit `pm-type-update` through the new `_broadcast_type_update`, with the Type's fresh `PMTypeBluePrint`. An edit that changes the effective set of outer Types emits one update per affected Type, the edited one first, then each nester in `_nesting_prefixes` order. `remove_type` emits one update with a `None` payload. Every parameter that `add_type_parameter`, `add_nested_type` or `add_instance` actually creates emits a `parameter-creation` through `_broadcast_parameter_creation`, whose payload mirrors the Server's `_newOrDeleteParameterDetection`. Kept parameters and direct `add_parameter` calls emit nothing, and neither do refused calls or the read-only queries. `test/pytest/test_pm_types.py` grew from 90 to 111 tests: server-free sink tests on a `pm_with_sink` fixture, and four proxy tests that follow `test_pm_locks.py`. + +### Commit by commit +- `9de2239` The emissions and 18 tests (14 sink, 4 proxy). The orchestrator's eight readings in the coder spec set these rules: + - which methods emit (the Phase 3 `lock_type_parameter`/`unlock_type_parameter` don't exist yet) + - outer Types get their own update + - `add_instance` emits only `parameter-creation`s + - payload shapes mirror 1.3 and the Server + - creations go first, in creation order, then the Type updates, all after the whole mutation + - refused calls emit nothing + - `set_type_parameter_unit`'s propagation emits no value Broadcast + + The coder added two readings of its own. `set_type_parameter_default` emits for the edited Type only, because `effective` carries unit and `from_type` but no defaults, so no outer blueprint changes. Creations are collected during the loop and broadcast after it. All six reviewers judged both readings as fitting the plan. The sink tests include one per method, plus these: + - `test_add_instance_emits_one_creation_per_created_parameter` + - `test_add_instance_emits_nothing_for_kept_parameters` + - `test_read_only_type_queries_emit_nothing` + - `test_failed_type_validations_emit_nothing`, which runs about 19 refusals and, with two direct `add_parameter` calls under the sink, also pins the plan's "none for direct `add_parameter`" + - `test_type_broadcast_payloads_are_snapshots_of_their_time` + + The four proxy tests are the plan's list: + - `test_every_type_method_is_callable_through_the_proxy` (all 13 D16 methods, `instances_of`/`types_of` included) + - `test_get_type_and_list_types_deserialise_over_the_wire` + - `test_subclient_sees_pm_type_update_and_creations_from_a_second_client` + - `test_the_first_clients_proxy_shows_the_created_parameters_after_update` + + Orchestrator run: ruff clean, 108 in `test_pm_types.py`, 346 in the full suite. +- `80635c3` Fix from round 0, test only, three tests: + - `test_pm_type_update_reaches_every_type_of_a_three_tier_nesting_chain`: every sink test nested only one level (`put_nested_instance`), so a `_nesting_prefixes` walk that stopped at direct nesters would have passed the suite and left the outermost Type's blueprint stale on every GUI. The test uses `put_three_tier_registry` and `add_type_parameter("pulse_window", "amp", ...)`, then expects exactly three updates in the order `pulse_window`, `readout`, `qubit`, with `readout.pw.amp` in `qubit`'s `effective`. Both test reviewers caught it (should-fix). + - `test_add_type_parameter_emits_no_creation_for_kept_parameters` and `test_add_nested_type_emits_no_creation_for_kept_parameters`: the kept-parameter rule had a sink test only for `add_instance`, not for the two 2.3 creation loops. Both general reviewers raised it as a nit. The orchestrator sent it anyway, because both models of one role raised it, the test was cheap and a fix round was happening regardless. + + The coder backed up `params.py` under `orchestration/2.5/`, applied two temporary mutations to show that the new tests fail, and restored the file. The orchestrator and three reviewers checked that `git diff 9de2239..80635c3 -- src/` is empty. All six approved in re-review with no findings, and every raiser confirmed their item fixed. Orchestrator run: ruff clean, 111 in `test_pm_types.py`, 349 in the full suite. + +### Dropped findings +- The wire-level `add_instance` test creates only one parameter, so "one `parameter-creation` per created parameter" is shown over the wire only for n=1 (test-reviewer-glm, nit). The only multi-creation sink test creates `q01.IF` then `q01.octave_gain`, which is also alphabetical order, so a loop that sorted paths would still pass (test-reviewer-qwen, nit) → not sent. Multiplicity and order rest on the sink tests. +- `remove_type` builds its `None` broadcast inline, because `_broadcast_type_update` calls `get_type` on the Type that was just deleted (reviewer-glm, nit). Both type-update sites spell the name as `f"{self.name}.{type_name}"` instead of `_full_path` (reviewer-qwen, nit) → not sent. The strings are identical. +- The creation-broadcast loop appears three times. The refused-call battery lacks the `_check_creation_targets` refusals (plan-checker-glm, nits). The `ParameterManager` class docstring leaves out the `None` payload and "per affected Type". A proxy-test comment says "propagated unit" where no Instance existed yet (plan-checker-qwen, nits) → not sent. All four are still in the code. + +### Questions to Marcos +- The orchestrator flagged its eight coder-spec readings for the run report. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- The 2.2 loose end on proxy coverage of `instances_of`/`types_of` is closed by `test_every_type_method_is_callable_through_the_proxy`. The 2.1 loose end on `get_type` aliasing is now partly pinned by `test_type_broadcast_payloads_are_snapshots_of_their_time`. +- For 3.2: `lock_type_parameter`/`unlock_type_parameter` must emit `pm-type-update` too (D22). +- The wire tests' negative counts (`len(received) == 1` right after `wait_for_broadcasts`) could in principle race a late Broadcast, as in `test_pm_locks.py`. The strong forms of those claims live in the sink tests (test-reviewer-glm, observation). +- Nothing from 2.5 is in `TEST_AUDIT.md`. + +### Process notes +- reviewer-qwen's `git -C` commands with a line-wrapped path slipped past the whitelist several times and needed manual approval. They were read-only and all were allowed. Its scratch script under `orchestration/2.5/` was re-scanned before each of its three runs and deleted afterwards. Reviewers and the coder ran the suite to log files in the folder and removed them. There were no rejected permissions, no stalls and no nudges. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 3669b16..91e4eee 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -495,7 +495,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session - [x] **2.4 `add_instance`.** Per D14, including the up-front unit-conflict scan that raises listing every conflicting path before creating anything, dotted (nested) names, and `_globals` refusal. Tests: `test_pm_types.py`. -- [ ] **2.5 `pm-type-update` and side-effect broadcasts.** Every Type-editing method emits +- [x] **2.5 `pm-type-update` and side-effect broadcasts.** Every Type-editing method emits `pm-type-update` (D22) with the updated `PMTypeBluePrint` (or `None` on `remove_type`). Every parameter created by 2.3/2.4 emits `parameter-creation` through `self.broadcast`; none is emitted for direct `add_parameter` calls (the server does those). Tests: From 8daf19525e732ee6a6825979984aba22d7cfbd44 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 22:53:45 -0500 Subject: [PATCH 050/107] =?UTF-8?q?3.1:=20=5Fglobals=20rules=20=E2=80=94?= =?UTF-8?q?=20add=5Fparameter=20refusal,=20matching=20exclusion=20re-asser?= =?UTF-8?q?ted,=20and=20the=20=5Fensure=5Fglobal=5Ftarget=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/instrumentserver/params.py | 120 ++++++++++++++ test/pytest/test_pm_types.py | 286 ++++++++++++++++++++++++++++++++- 2 files changed, 403 insertions(+), 3 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index c1600cf..7236b91 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -472,6 +472,15 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] instrument name (``parameter_manager.q01.x``), the form Locks and files use. + Raises ``ValueError`` naming the offending path, creating nothing, + when ``name`` is the reserved Globals name ``_globals`` or starts + with it (D18): the Globals submodule holds only the default + Targets of Type Locks, and its parameters are created on demand by + :meth:`_ensure_global_target`, not through the public API. Since a + Parameter Group routes its ``add_parameter`` to the root (D15), + this check covers calls made on any Parameter Group of this + Parameter Manager as well. + :param name: Name of the parameter; see :meth:`ParameterGroup.add_parameter`. :param kw: Any keyword arguments will be passed on to @@ -479,6 +488,13 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] :meth:`ParameterGroup.add_parameter`. :return: None. """ + # validate-then-mutate: the Globals refusal runs before anything + # is created (rule 3) + if name == "_globals" or name.startswith("_globals."): + raise ValueError( + f"'{name}' is not a valid parameter path: " + "the Globals submodule name is reserved" + ) kw["parameter_class"] = ManagedParameter kw["path"] = f"{self.name}.{name}" super().add_parameter(name, **kw) @@ -1817,6 +1833,110 @@ def add_instance(self, type_name: str, name: str) -> None: created_path, initial_value, created_unit ) + # ------------------------------------------------------------------ + # Globals (plan decision D18) + # + # The reserved Globals submodule ``_globals`` holds the default + # Targets of Type Locks. It is created on demand and is never an + # Instance; matching excludes it and everything under it (D12, the + # walk in ``_iter_submodule_groups``), and ``add_parameter`` refuses + # its name. The internal helper ``_ensure_global_target`` creates + # ``_globals..`` through the internal creation path — + # the same one the public ``add_parameter`` ends in. A Globals + # parameter is otherwise ordinary (D18): it can be set and read, it + # may itself carry a Lock and be a Lock Target, and it is saved with + # the profile. Removing one is allowed; the Lock and Type Lock + # cleanup it triggers is task 3.3. + # ------------------------------------------------------------------ + + def _ensure_global_target(self, type_name: str, path: str) -> str: + """Create the Globals parameter ``_globals..`` + for the Type ``type_name``'s own entry at ``path`` — on demand, + with the entry's default value and unit — and return its dotted + path relative to this Parameter Manager (D17, D18). + + This is the internal helper a Type Lock declaration builds its + default Target with; nothing public calls it yet (task 3.2 will). + It bypasses the public :meth:`add_parameter` refusal of the + Globals name through the internal creation path + (``_get_parent(..., create_parent=True)`` + + ``_add_own_parameter``), creating the parameter as a + :class:`ManagedParameter` whose ``path`` is the full dotted form + with the instrument name, like :meth:`add_parameter` does. A + parameter that exists at the target path already is kept + untouched — its own value is not changed and nothing is emitted + (created on demand, D18). + + Raises ``ValueError`` — before anything is touched — naming the + offending name or path when no such Type exists, when ``path`` is + not an entry of the Type itself (naming the Type that defines it + when the path only reaches the effective parameter set through a + Nested Type; own entries only, like + :meth:`set_type_parameter_default`), when the parameter exists + with a unit different from the entry's unit (naming the path and + both units; unit conflicts are refused like D14), when a segment + of ``_globals..`` on the way is an existing + parameter rather than a Parameter Group, or when the target path + is an existing Parameter Group. + + When it creates the parameter it emits exactly one + ``parameter-creation`` Broadcast in the same shape the Type-edit + side-effect creations use (D22, ADR-0003); it edits no Type, so + it emits no ``pm-type-update``. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the Type's own entry. + :return: The path ``"_globals.."``. + """ + # validate-then-mutate: every check below runs before the tree is + # touched (rule 3) + entry = self._require_type_entry(type_name, path) + global_path = f"_globals.{type_name}.{path}" + if self.has_param(global_path): + existing_unit = getattr(self.parameter(global_path), "unit", None) + if existing_unit != entry.unit: + raise ValueError( + f"cannot create the Globals parameter '{global_path}': " + f"it exists already with unit '{existing_unit}', the " + f"entry of Type '{type_name}' declares '{entry.unit}'" + ) + # created on demand: an existing parameter is kept untouched, + # with its own value, and emits nothing (D18) + return global_path + # a parameter on the way blocks the creation, and the target path + # may not be an existing Parameter Group: both are checked before + # anything is created. A missing Parameter Group is created on the + # way, so nothing deeper along the path can clash behind it. + segments = global_path.split(".") + group: ParameterGroup = self + walked: List[str] = [] + missing_group = False + for segment in segments[:-1]: + if segment in group.parameters: + blocked = ".".join([*walked, segment]) + raise ValueError( + f"'{blocked}' is a parameter, and cannot have " + "child parameters" + ) + submodule = group.submodules.get(segment) + if submodule is None: + missing_group = True + break + walked.append(segment) + group = submodule + if not missing_group and segments[-1] in group.submodules: + raise ValueError(f"'{global_path}' is already a Parameter Group") + parent = self._get_parent(global_path, create_parent=True) + parent._add_own_parameter( + segments[-1], + parameter_class=ManagedParameter, + path=self._full_path(global_path), + initial_value=entry.default, + unit=entry.unit, + ) + self._broadcast_parameter_creation(global_path, entry.default, entry.unit) + return global_path + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 5850eac..e23b215 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -1,8 +1,9 @@ """Tests for the Type registry and definitions (plan task 2.1), the duck-typed Instance matching (plan task 2.2), the Type edits with -Instance side effects (plan task 2.3), ``add_instance`` (plan task 2.4) -and the ``pm-type-update`` and side-effect creation Broadcasts (plan task -2.5). +Instance side effects (plan task 2.3), ``add_instance`` (plan task 2.4), +the ``pm-type-update`` and side-effect creation Broadcasts (plan task +2.5) and the Globals rules with the ``_ensure_global_target`` helper +(plan task 3.1). The definition and editing methods are exercised through the public API: ``add_type`` / ``add_type_parameter`` / ``add_nested_type`` and friends. @@ -35,6 +36,21 @@ per-parameter ``parameter-creation`` Broadcasts of an ``add_instance`` issued from a second client, whose creations the first client's proxy shows after ``update()``. +The Globals part checks that ``add_parameter`` refuses the reserved +Globals name (naming the offending path and creating nothing, through +the root and through a Parameter Group's routed call), that +``add_instance`` under Globals still raises, that Globals parameters +created by the internal ``_ensure_global_target`` helper stay excluded +from ``instances_of``/``types_of`` even when they carry a full Instance +shape, and the helper itself: creating ``_globals..`` with +the entry's default and unit as a ``ManagedParameter``, returning the +dotted path, emitting exactly one ``parameter-creation`` and nothing on +a second (idempotent) call, refusing an unknown Type, a path that is not +an own entry, a unit conflict on the existing parameter, a parameter on +the way and a Parameter Group at the target — each leaving the tree +byte-identical — and that a Globals parameter is otherwise ordinary (set, +read, Target of a Lock). The proxy part exercises the ``add_parameter`` +refusal over the wire. """ import copy @@ -46,6 +62,7 @@ PARAMETER_CREATION, PM_TYPE_UPDATE, ParameterBroadcastBluePrint, + PMLockBluePrint, PMTypeBluePrint, deserialize_obj, ) @@ -1768,6 +1785,256 @@ def test_add_instance_on_an_empty_type_creates_nothing(pm): assert pm.instances_of("empty") == [] +# --------------------------------------------------------------------------- +# Globals rules and _ensure_global_target (plan task 3.1, D18) +# +# The reserved Globals submodule ``_globals`` holds the default Targets of +# Type Locks. The public ``add_parameter`` refuses its name (nothing is +# created), ``add_instance`` under it keeps raising (2.4), and matching +# keeps excluding it (2.2). The internal helper ``_ensure_global_target`` +# creates ``_globals..`` on demand, with the entry's default +# and unit; the parameter is otherwise ordinary. +# --------------------------------------------------------------------------- + + +def test_add_parameter_refuses_the_globals_submodule(pm): + pm.add_parameter("q01.IF", unit="Hz") + before = sorted(pm.list()) + + for name in ("_globals", "_globals.x", "_globals.qubit.IF"): + with pytest.raises( + ValueError, + match=re.escape(f"'{name}' is not a valid parameter path"), + ): + pm.add_parameter(name) + + # nothing was created: the tree is byte-identical, and the Globals + # submodule does not exist + assert sorted(pm.list()) == before + assert "_globals" not in pm.submodules + + +def test_add_parameter_refusal_covers_the_parameter_group_routing(pm): + # a Parameter Group routes add_parameter to the root (D15), so the + # refusal on the root covers the pm._globals.add_parameter(...) call + # style too + put_globals_parameter(pm, "_globals.qubit.IF", unit="Hz") + pm.add_parameter("q01.IF", unit="Hz") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape("'_globals.qubit.IF' is not a valid parameter path"), + ): + pm._globals.add_parameter("qubit.IF") + + # the refusal does not over-fire: a routed call without the Globals + # name still adds the parameter + pm.q01.add_parameter("sub", unit="s") + + assert sorted(pm.list()) == sorted(before + ["q01.sub"]) + + +def test_add_instance_still_refuses_the_globals_submodule(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm._ensure_global_target("qubit", "IF") + before = sorted(pm.list()) + + with pytest.raises(ValueError, match="the Globals submodule name is reserved"): + pm.add_instance("qubit", "_globals") + with pytest.raises(ValueError, match="the Globals submodule name is reserved"): + pm.add_instance("qubit", "_globals.q01") + + # nothing was created + assert sorted(pm.list()) == before + + +def test_globals_parameters_stay_excluded_from_matching(pm): + # a Globals parameter is never an Instance and is claimed by nothing + # (D12, D18) — even when the Globals subtree carries a full Instance + # shape, with every effective path at the declared unit + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm._ensure_global_target("qubit", "IF") + pm._ensure_global_target("qubit", "octave_gain") + put_globals_parameter(pm, "_globals.deep.qubit.IF", unit="Hz") + put_globals_parameter(pm, "_globals.deep.qubit.octave_gain", unit="dB") + + assert pm.instances_of("qubit") == [] + assert pm.types_of("_globals.qubit.IF") == [] + assert pm.types_of("_globals.qubit.octave_gain") == [] + assert pm.types_of("_globals.deep.qubit.IF") == [] + + +def test_ensure_global_target_creates_the_parameter_with_default_and_unit(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + path = pm._ensure_global_target("qubit", "IF") + + # the relative dotted path is returned (rule 4: names are strings) + assert path == "_globals.qubit.IF" + assert pm.has_param(path) + assert pm.get(path) == 5e9 + assert pm.parameter(path).unit == "Hz" + # created as a ManagedParameter with the full dotted path, the way + # add_parameter does + param = pm.parameter(path) + assert isinstance(param, ManagedParameter) + assert param.path == "parameter_manager._globals.qubit.IF" + # the Parameter Groups on the way were created + assert "qubit" in pm._globals.submodules + + # a dotted entry path creates the Parameter Groups on the way too + pm.add_type("sensor") + pm.add_type_parameter("sensor", "sub.x", default=1, unit="V") + assert pm._ensure_global_target("sensor", "sub.x") == "_globals.sensor.sub.x" + assert pm.get("_globals.sensor.sub.x") == 1 + assert pm.parameter("_globals.sensor.sub.x").unit == "V" + + +def test_ensure_global_target_emits_one_creation_and_is_idempotent(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + received = [] + pm.add_broadcast_sink(received.append) + + path = pm._ensure_global_target("qubit", "IF") + + # exactly one parameter-creation, in the same shape the Type-edit + # side-effect creations use (D22, ADR-0003); it edits no Type, so no + # pm-type-update goes out + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager._globals.qubit.IF" + assert bp.action == PARAMETER_CREATION + assert bp.value == 5e9 + assert bp.unit == "Hz" + + received.clear() + pm.set(path, 42.0) + assert pm._ensure_global_target("qubit", "IF") == path + # created on demand: the existing parameter is kept untouched, its own + # value stands, and nothing is emitted (D18) + assert pm.get(path) == 42.0 + assert received == [] + + +def test_ensure_global_target_refuses_an_unknown_type(pm): + pm.add_parameter("q01.IF", unit="Hz") + before = sorted(pm.list()) + + with pytest.raises(ValueError, match="no Type named 'nope' exists"): + pm._ensure_global_target("nope", "IF") + + assert sorted(pm.list()) == before + assert "_globals" not in pm.submodules + + +def test_ensure_global_target_refuses_a_path_that_is_not_an_own_entry(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape("parameter path 'nope' is not an entry of Type 'qubit'"), + ): + pm._ensure_global_target("qubit", "nope") + # a path only reached through a Nested Type is refused too: own entries + # only, like set_type_parameter_default and set_type_parameter_unit + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is not an entry of Type 'qubit' " + "itself: it is only in the effective set through the entry of " + "Type 'readout'" + ), + ): + pm._ensure_global_target("qubit", "readout.IF") + + assert sorted(pm.list()) == before + assert "_globals" not in pm.submodules + + +def test_ensure_global_target_refuses_a_unit_conflict_on_the_existing_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + # an existing Globals parameter carrying a unit the entry does not + # declare: unit conflicts are refused like D14 + put_globals_parameter(pm, "_globals.qubit.IF", unit="V") + before = sorted(pm.list()) + + with pytest.raises(ValueError) as excinfo: + pm._ensure_global_target("qubit", "IF") + + # the path and both units are named + message = str(excinfo.value) + assert "'_globals.qubit.IF'" in message + assert "'V'" in message + assert "'Hz'" in message + # refused before anything is touched: the parameter keeps its unit + assert sorted(pm.list()) == before + assert pm.parameter("_globals.qubit.IF").unit == "V" + + +def test_ensure_global_target_refuses_a_parameter_on_the_way(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + # a parameter takes the place the Globals Parameter Group qubit needs + put_globals_parameter(pm, "_globals.qubit") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape( + "'_globals.qubit' is a parameter, and cannot have child parameters" + ), + ): + pm._ensure_global_target("qubit", "IF") + + assert sorted(pm.list()) == before + + +def test_ensure_global_target_refuses_a_parameter_group_at_the_target(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + # the Parameter Group _globals.qubit.IF occupies the target path + put_globals_parameter(pm, "_globals.qubit.IF.sub", unit="s") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape("'_globals.qubit.IF' is already a Parameter Group"), + ): + pm._ensure_global_target("qubit", "IF") + + assert sorted(pm.list()) == before + + +def test_a_globals_parameter_is_an_ordinary_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=4e9, unit="Hz") + path = pm._ensure_global_target("qubit", "IF") + + # set and read like any parameter of the Parameter Manager (D18) + pm.set(path, 6e9) + assert pm.get(path) == 6e9 + + # and it can be the Target of a Lock: the Follower pulls its value + pm.lock("q01.IF", path) + assert pm.get("q01.IF") == 6e9 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.followers_of(path) == ["q01.IF"] + + # --------------------------------------------------------------------------- # pm-type-update and side-effect creation Broadcasts (plan task 2.5, D22) # @@ -2371,3 +2638,16 @@ def test_the_first_clients_proxy_shows_the_created_parameters_after_update( finally: second_cli.disconnect() _cleanup_proxy_types(params) + + +def test_add_parameter_refusal_over_the_wire(param_manager): + cli, params = param_manager + before = sorted(params.list()) + + # the ValueError the server-side Parameter Manager raises reaches the + # client as an exception carrying the same message + with pytest.raises(Exception, match="the Globals submodule name is reserved"): + params.add_parameter("_globals.x") + + # nothing was created over the wire either + assert sorted(params.list()) == before From 175ff5a03e64fe759980c2ee23b7f62d4a448bba Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 23:08:35 -0500 Subject: [PATCH 051/107] 3.1: fix from review round 1: delegate the blocked-target walk to _check_creation_targets and note the Globals profile-load gap in TEST_AUDIT --- TEST_AUDIT.md | 1 + src/instrumentserver/params.py | 36 +++++++++++----------------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 9e2388f..e9859a7 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -37,6 +37,7 @@ States: | client.md | Parameter snapshots | Relative Client-side paths, selected and all-instrument save/restore, flat and nested shapes, and nested `setParameters` rejection match the guide. Native JSON booleans still become `0.0`/`1.0` and fail QCoDeS Boolean validation | `section_save_and_restore_parameter_values` in `verify_client.py` | covered | `test_parameter_snapshot_files_and_current_boolean_limitation` covers the end-to-end workflow and explicitly references open product bug #152. The issue remains open and is not fixed in this documentation pass | | client.md | Errors and timeouts | Server validation failures arrive as generic `Exception` objects. A timeout discards the old socket, connects a replacement without retrying, lets the original Server call finish exactly once, and permits later requests. `raise_exceptions=False` logs and returns `None`; `disconnect()` is permanent | `section_handle_errors_and_timeouts` in `verify_client.py` | covered | `test_server_errors_timeout_socket_replacement_and_quiet_mode` and `test_timeout_dummy_responds_to_idn` | | user_guide/parameter_manager.md (future) | Types — `get_type` through a proxy | `bluePrintToDict` stringifies scalar leaves and `deserialize_obj` re-parses them numerically, so a Type entry `default` such as the string `"10"` comes back as the int `10` over the wire | Found during the plan 2.1 review; reproduced by round-tripping a `PMTypeBluePrint` through `bluePrintToDict`/`deserialize_obj` | gap | Pre-existing wire-format limitation shared with every blueprint payload (e.g. `PMLockBluePrint`, `ParameterBroadcastBluePrint.value`); not introduced by 2.1; noted per plan rule 6, not fixed here | +| user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create every missing parameter through the public `add_parameter`, which since task 3.1 refuses names under `_globals`, so a profile file holding a `_globals.*` key raises `ValueError` on load until the Phase 4 reader (task 4.2) creates Globals parameters through the internal path | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)` | gap | Interim gap by plan sequencing (D18, D19, task 4.2), not fixed in 3.1 per plan rule 6 | ## Manual checks diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 7236b91..4c89602 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -1874,8 +1874,10 @@ def _ensure_global_target(self, type_name: str, path: str) -> str: Nested Type; own entries only, like :meth:`set_type_parameter_default`), when the parameter exists with a unit different from the entry's unit (naming the path and - both units; unit conflicts are refused like D14), when a segment - of ``_globals..`` on the way is an existing + both units; unit conflicts are refused like D14), and — through + the same :meth:`_check_creation_targets` validation the Type + edits and :meth:`add_instance` run — when a segment of + ``_globals..`` on the way is an existing parameter rather than a Parameter Group, or when the target path is an existing Parameter Group. @@ -1904,31 +1906,15 @@ def _ensure_global_target(self, type_name: str, path: str) -> str: # with its own value, and emits nothing (D18) return global_path # a parameter on the way blocks the creation, and the target path - # may not be an existing Parameter Group: both are checked before - # anything is created. A missing Parameter Group is created on the - # way, so nothing deeper along the path can clash behind it. - segments = global_path.split(".") - group: ParameterGroup = self - walked: List[str] = [] - missing_group = False - for segment in segments[:-1]: - if segment in group.parameters: - blocked = ".".join([*walked, segment]) - raise ValueError( - f"'{blocked}' is a parameter, and cannot have " - "child parameters" - ) - submodule = group.submodules.get(segment) - if submodule is None: - missing_group = True - break - walked.append(segment) - group = submodule - if not missing_group and segments[-1] in group.submodules: - raise ValueError(f"'{global_path}' is already a Parameter Group") + # may not be an existing Parameter Group: the blocked-target + # validation is the one ``_check_creation_targets`` already owns + # for the Type edits and ``add_instance`` — the single target here + # is an Instance at the reserved Globals submodule, whose missing + # Parameter Groups are created on the way + self._check_creation_targets([("_globals", f"{type_name}.{path}")]) parent = self._get_parent(global_path, create_parent=True) parent._add_own_parameter( - segments[-1], + global_path.split(".")[-1], parameter_class=ManagedParameter, path=self._full_path(global_path), initial_value=entry.default, From 402e53dfceb486183b1f8677952b990ab810172c Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 23:16:45 -0500 Subject: [PATCH 052/107] 3.1: fix from review round 2: reword the _ensure_global_target comment so the Globals target is not called an Instance --- src/instrumentserver/params.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 4c89602..0522092 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -1908,9 +1908,11 @@ def _ensure_global_target(self, type_name: str, path: str) -> str: # a parameter on the way blocks the creation, and the target path # may not be an existing Parameter Group: the blocked-target # validation is the one ``_check_creation_targets`` already owns - # for the Type edits and ``add_instance`` — the single target here - # is an Instance at the reserved Globals submodule, whose missing - # Parameter Groups are created on the way + # for the Type edits and ``add_instance``. The single target here + # is ``.`` under the reserved Globals submodule — + # which is never an Instance (D18); the ``(Instance path, + # relative target)`` pair the helper takes is reused only for its + # walk, and missing Parameter Groups are created on the way self._check_creation_targets([("_globals", f"{type_name}.{path}")]) parent = self._get_parent(global_path, create_parent=True) parent._add_own_parameter( From 823facc4c2383825244071f4501e950dc09a9256 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 24 Sep 2026 23:39:17 -0500 Subject: [PATCH 053/107] 3.1: history --- HISTORY_parameter_manager_redesign.md | 44 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index e49825e..47ad7b0 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -441,3 +441,47 @@ The Type API in `src/instrumentserver/params.py` now emits its own Broadcasts (D ### Process notes - reviewer-qwen's `git -C` commands with a line-wrapped path slipped past the whitelist several times and needed manual approval. They were read-only and all were allowed. Its scratch script under `orchestration/2.5/` was re-scanned before each of its three runs and deleted afterwards. Reviewers and the coder ran the suite to log files in the folder and removed them. There were no rejected permissions, no stalls and no nudges. + +## 3.1 `_globals` rules — 2026-09-24 + +`ParameterManager.add_parameter` now refuses, with a `ValueError` naming the path and creating nothing, any name that is `_globals` or starts with `_globals.` (D18). Parameter Groups route `add_parameter` to the root, so the refusal covers `pm._globals.add_parameter(...)` style calls too. The new private `_ensure_global_target(type_name, path)` creates `_globals..` on demand for one of the Type's own entries. It creates it as a `ManagedParameter` with the entry's default and unit and a full-form `path`, emits one `parameter-creation`, and returns the relative path. An existing parameter is kept with its value and emits nothing. Nothing public calls it yet (3.2 will). `add_instance`'s 2.4 refusal and 2.2's matching exclusion are asserted again. `test/pytest/test_pm_types.py` grew from 111 to 124 tests: twelve unit tests and one proxy test. + +### Commit by commit +- `8daf195` The refusal, the helper and 13 tests. The orchestrator's six readings in the coder spec set these rules: + - "under `_globals`" means the first dotted segment, and a `_globals` segment after the first stays allowed (not widened) + - the refusal lives on the root `add_parameter` + - the helper acts on the Type's own entries only (a path reached only through a Nested Type raises, naming the defining Type, as in `set_type_parameter_default`) + - it is idempotent and refuses a unit conflict on an existing Globals parameter, naming the path and both units + - it bypasses the public refusal through `_get_parent(..., create_parent=True)` + `_add_own_parameter` + - it emits one `parameter-creation` when it creates and no `pm-type-update` + + The coder added one reading of its own: a target path that is an existing Parameter Group is refused too. Every refusal test also checks that `list()` is unchanged. The tests include: + - `test_add_parameter_refusal_covers_the_parameter_group_routing` + - `test_add_instance_still_refuses_the_globals_submodule` + - `test_globals_parameters_stay_excluded_from_matching`, which builds a full Instance shape at `_globals.qubit` and `_globals.deep.qubit` + - `test_ensure_global_target_emits_one_creation_and_is_idempotent` + - `test_a_globals_parameter_is_an_ordinary_parameter` (set, read, and a Lock Target) + - `test_add_parameter_refusal_over_the_wire`, which catches a bare `Exception`, because the Client rebuilds a plain `Exception` from the Server's error message + + Orchestrator run: ruff clean, 124 in `test_pm_types.py`, 362 in the full suite. +- `175ff5a` Fix from round 0, two items: + - `_ensure_global_target` had its own segment walk for a parameter on the way and a Parameter Group at the target. That repeated `_check_creation_targets` line for line. reviewer-qwen (should-fix) and reviewer-glm (nit) raised it. The walk is now `self._check_creation_targets([("_globals", f"{type_name}.{path}")])`. The two affected tests pass unchanged, because the helper only wraps the same reasons in "cannot create parameter '': ...". + - A `TEST_AUDIT.md` row, "Profiles — loading Globals parameters". `fromParamDict`/`fromFile` create missing parameters through the public `add_parameter`, so a profile with a `_globals.*` key now raises on load. reviewer-qwen reproduced it and reviewer-glm noted it. It is left for the Phase 4 reader (4.2) per plan rule 6. +- `402e53d` Fix from round 1, comment only. The new comment in `175ff5a` called the Globals target "an Instance at the reserved Globals submodule". That breaks D18 and plan rule 2. reviewer-qwen (should-fix) and both test reviewers (nit) caught it. The comment now says the target sits under the Globals submodule, "which is never an Instance (D18)", and that the `(Instance path, relative target)` pair is reused only for the walk. All six approved in re-review with no findings. Orchestrator run: ruff clean, 124 in `test_pm_types.py`, 362 in the full suite. + +### Dropped findings +- Globals parameters are created without the `vals=Anything()` and `set_cmd=None` that the public `add_parameter` path gives (reviewer-glm, reviewer-qwen, nits) → not sent. Only the snapshot's `vals` meta differs, and nothing reads it. The orchestrator suggested folding both paths into one internal creation helper later. +- No positive test that a non-first `_globals` segment (`add_parameter("q01._globals.x")`) is still allowed (test-reviewer-qwen, nit) → not sent. A later task that widens the check will break no test. +- No test for a root parameter named `_globals` blocking the helper (reviewer-qwen, nit) → not sent. That case can now be built only through the internal path. +- The `add_parameter` docstring says a name "starts with it" where the code checks `startswith("_globals.")` (plan-checker-qwen, nit) → not sent. The wording is still in `params.py`. + +### Questions to Marcos +- The orchestrator flagged its six coder-spec readings for the run report, especially keeping non-first `_globals` segments allowed. It also flagged the `vals` difference and the profile-load gap. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- The RUNS.md question for 3.1 is not settled. A `_globals` segment after the first is still accepted in `add_parameter` names, Type entry paths, `add_nested_type` submodule names and `add_instance` names, and matching then skips that submodule without saying so. +- For 4.2: the reader must create `_globals.*` parameters through the internal path (`TEST_AUDIT.md`, "Profiles — loading Globals parameters"). +- For 3.2: `_ensure_global_target` is ready for the Type Lock declaration to call. + +### Process notes +- Two permissions were rejected. In round 0, reviewer-qwen tried an inline `python -c` script that changed into a `tempfile.mkdtemp()` outside the repo. It reran its check as a scanned script under `orchestration/3.1/round-0/`. In round 1, plan-checker-qwen tried a command that wrote to `/tmp/x`. Reviewers' scratch scripts and logs under `orchestration/3.1/` were scanned before they ran and deleted afterwards. There were no stalls and no nudges. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 91e4eee..fb7d4b7 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -506,7 +506,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 3 — Type Locks and `_globals` -- [ ] **3.1 `_globals` rules.** Per D18: `add_parameter` and `add_instance` under `_globals` +- [x] **3.1 `_globals` rules.** Per D18: `add_parameter` and `add_instance` under `_globals` raise; `_globals` excluded from `instances_of`/`types_of` (already in 2.2, assert again); internal helper `_ensure_global_target(type, path)` creating `_globals..` with the entry's default and unit. Tests: `test_pm_types.py`. From 682ab21951217243f1ea75601fbb4e34ac2aeee9 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 08:39:06 -0500 Subject: [PATCH 054/107] 3.2: lock_type_parameter / unlock_type_parameter with Type Lock application to new Instances --- src/instrumentserver/params.py | 419 ++++++++++++++-- test/pytest/test_pm_types.py | 844 ++++++++++++++++++++++++++++++++- 2 files changed, 1234 insertions(+), 29 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 0522092..fa6cd9e 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -423,7 +423,12 @@ class ParameterManager(Broadcaster, ParameterGroup): Type edits and :meth:`add_instance` create as side effects are re-emitted as ``parameter-creation`` Broadcasts (ADR-0003); direct ``add_parameter`` calls keep being announced by the Server, so nothing - is announced twice. + is announced twice. Declaring a Type Lock with + :meth:`lock_type_parameter` and removing it with + :meth:`unlock_type_parameter` do both: they emit the + ``pm-lock-update`` Broadcasts of the Locks the declaration creates and + the ``pm-type-update`` of the edited Type, and every new Instance gets + the existing Type Locks of its Type at creation (D17). For the parameter manager to recognize other profiles in disk, the profile filename needs to start with 'parameter_manager-' @@ -1208,10 +1213,12 @@ def types_of(self, path: str) -> List[str]: # # The Broadcasts go out only after the whole edit succeeded (D22): # one ``parameter-creation`` per created parameter, in creation - # order, then one ``pm-type-update`` per affected Type — the edited - # Type first, then every Type nesting it, whose effective parameter - # set the edit changed too. The affected Types are the keys of - # ``_nesting_prefixes``, which the side-effect computation already + # order, then the Type Locks applied to the submodules the edit + # turned into new Instances (D17), each emitting one + # ``pm-lock-update``, then one ``pm-type-update`` per affected Type — + # the edited Type first, then every Type nesting it, whose effective + # parameter set the edit changed too. The affected Types are the keys + # of ``_nesting_prefixes``, which the side-effect computation already # walks. A refused edit emits nothing. # ------------------------------------------------------------------ @@ -1398,11 +1405,17 @@ def add_type_parameter( edit is a strict segment-prefix of it. After the edit succeeds it emits one ``parameter-creation`` - Broadcast per parameter it created, in creation order, followed - by one ``pm-type-update`` Broadcast per affected Type — the - edited Type first, then every Type nesting it, whose effective - parameter set the new entry extends (D22, ADR-0003); a failed - validation emits nothing. + Broadcast per parameter it created, in creation order, followed by + one ``pm-lock-update`` Broadcast per Type Lock it applied to a new + Instance and one ``pm-type-update`` Broadcast per affected Type — + the edited Type first, then every Type nesting it, whose effective + parameter set the new entry extends (D17, D22, ADR-0003); a failed + validation emits nothing. A new Instance — a submodule that is an + Instance of an affected Type after the edit but was not one before + — gets the existing Type Locks of that Type applied to its + parameters at the Type's locked effective entries, parameters the + edit kept included; skips are collected into one ``logger.warning`` + and the edit still succeeds. :param type_name: Name of the Type. :param path: Relative parameter path of the entry. @@ -1469,11 +1482,13 @@ def add_type_parameter( creations.append((full, default, unit)) # broadcasts after the whole edit succeeded (D22): one # parameter-creation per created parameter in creation order, - # then one pm-type-update per affected Type, the edited Type first + # then the Type Locks of the new Instances (each emitting its + # pm-lock-update, D17), then one pm-type-update per affected Type for created_path, initial_value, created_unit in creations: self._broadcast_parameter_creation( created_path, initial_value, created_unit ) + self._apply_type_locks_to_new_instances(list(affected), instances_before) for name in affected: self._broadcast_type_update(name) @@ -1589,11 +1604,19 @@ def add_nested_type( another target of the same edit is a strict segment-prefix of it. After the edit succeeds it emits one ``parameter-creation`` - Broadcast per parameter it created, in creation order, followed - by one ``pm-type-update`` Broadcast per affected Type — the - edited Type first, then every Type nesting it, whose effective - parameter set the nested entries extend (D22, ADR-0003); a failed - validation emits nothing. + Broadcast per parameter it created, in creation order, followed by + one ``pm-lock-update`` Broadcast per Type Lock it applied to a new + Instance and one ``pm-type-update`` Broadcast per affected Type — + the edited Type first, then every Type nesting it, whose effective + parameter set the nested entries extend (D17, D22, ADR-0003); a + failed validation emits nothing. A new Instance — a submodule that + is an Instance after the edit but was not one before — gets the + existing Type Locks of its Type applied to its parameters at the + Type's locked effective entries, parameters the edit kept included; + the Nested Type's own chain joins the Types whose Type Locks are + applied, since nesting it completes the submodules at its position + into Instances of it; skips are collected into one + ``logger.warning`` and the edit still succeeds. :param type_name: Name of the outer Type. :param submodule: Name of the submodule that requires the Nested @@ -1645,6 +1668,15 @@ def add_nested_type( # every Type nesting it must not contain a path twice; computed # against the current registry, which the mutation below follows affected = self._nesting_prefixes(type_name) + # the Nested Type's own chain joins the Types whose Type Locks are + # applied to new Instances (D17): nesting readout into qubit + # completes the submodules at the readout position into Instances + # of readout, whose Type Locks must be applied too; the creations + # and the pm-type-updates keep using ``affected`` only + lock_types = list( + dict.fromkeys([*affected, *self._nesting_prefixes(nested_type)]) + ) + instances_before = {name: self.instances_of(name) for name in lock_types} nested_entries = self._effective_entries(nested_type) collisions: List[str] = [] for name in affected: @@ -1668,7 +1700,6 @@ def add_nested_type( f"'{submodule}' of Type '{type_name}': parameter path(s) " f"{', '.join(collisions)} would appear more than once" ) - instances_before = self._instances_before_edit(affected) targets = [ (instance_path, f"{prefix}{submodule}.{entry_path}", entry) for name, prefixes in affected.items() @@ -1694,11 +1725,14 @@ def add_nested_type( creations.append((full, entry.default, entry.unit)) # broadcasts after the whole edit succeeded (D22): one # parameter-creation per created parameter in creation order, - # then one pm-type-update per affected Type, the edited Type first + # then the Type Locks of the new Instances (each emitting its + # pm-lock-update, D17), then one pm-type-update per affected Type, + # the edited Type first for created_path, initial_value, created_unit in creations: self._broadcast_parameter_creation( created_path, initial_value, created_unit ) + self._apply_type_locks_to_new_instances(lock_types, instances_before) for name in affected: self._broadcast_type_update(name) @@ -1743,8 +1777,11 @@ def remove_nested_type(self, type_name: str, submodule: str) -> None: # # After the whole call succeeded it emits one ``parameter-creation`` # Broadcast per parameter it created, in creation order (D22, - # ADR-0003); it edits no Type, so it emits no ``pm-type-update``. A - # refused call emits nothing. + # ADR-0003), then applies the existing Type Locks of the Type and of + # every Type nesting it to the submodules that are Instances only + # after the call (D17) — each applied Lock emitting its + # ``pm-lock-update``; it edits no Type, so it emits no + # ``pm-type-update``. A refused call emits nothing. # ------------------------------------------------------------------ def add_instance(self, type_name: str, name: str) -> None: @@ -1774,9 +1811,19 @@ def add_instance(self, type_name: str, name: str) -> None: existing Parameter Group. After the Instance is created it emits one ``parameter-creation`` - Broadcast per parameter it created, in creation order (D22, - ADR-0003); the call edits no Type, so it emits no - ``pm-type-update``. A failed validation emits nothing. + Broadcast per parameter it created, in creation order, followed by + one ``pm-lock-update`` Broadcast per Type Lock it applied to the + new Instance (D17, D22, ADR-0003); the call edits no Type, so it + emits no ``pm-type-update``. A failed validation emits nothing. + As a new Instance, ``name`` gets the existing Type Locks of this + Type and of every Type nesting it applied to its parameters at + their locked effective entries — a parameter the call kept (D14) + included, since a Lock changes neither its own value nor its unit. + A kept parameter that already carries a Lock on another Target, a + stored Target that no longer exists and a Lock that would close a + cycle are skipped, collected into one ``logger.warning``; the + creation itself still succeeds. Submodules that were Instances + before the call are untouched. :param type_name: Name of the Type. :param name: Dotted submodule path of the Instance, relative to @@ -1818,6 +1865,11 @@ def add_instance(self, type_name: str, name: str) -> None: f"'{name}': " + "; ".join(conflicts) ) self._check_creation_targets([(name, path) for path in effective]) + # the Instances of this Type and of every Type nesting it, before + # anything is created: the submodules that are Instances only after + # the call get the existing Type Locks applied (D17) + lock_types = list(self._nesting_prefixes(type_name)) + instances_before = {name_: self.instances_of(name_) for name_ in lock_types} creations: List[Tuple[str, Any, str]] = [] for path, entry in effective.items(): full = f"{name}.{path}" @@ -1826,12 +1878,14 @@ def add_instance(self, type_name: str, name: str) -> None: full, initial_value=entry.default, unit=entry.unit ) creations.append((full, entry.default, entry.unit)) - # one parameter-creation per created parameter, in creation order - # (D22); no pm-type-update: the call edits no Type + # one parameter-creation per created parameter, in creation order, + # then the Type Locks of the new Instances (each emitting its + # pm-lock-update, D17); no pm-type-update: the call edits no Type for created_path, initial_value, created_unit in creations: self._broadcast_parameter_creation( created_path, initial_value, created_unit ) + self._apply_type_locks_to_new_instances(lock_types, instances_before) # ------------------------------------------------------------------ # Globals (plan decision D18) @@ -1855,8 +1909,8 @@ def _ensure_global_target(self, type_name: str, path: str) -> str: with the entry's default value and unit — and return its dotted path relative to this Parameter Manager (D17, D18). - This is the internal helper a Type Lock declaration builds its - default Target with; nothing public calls it yet (task 3.2 will). + This is the internal helper :meth:`lock_type_parameter` builds the + default Target of a Type Lock declaration with. It bypasses the public :meth:`add_parameter` refusal of the Globals name through the internal creation path (``_get_parent(..., create_parent=True)`` + @@ -1925,6 +1979,317 @@ def _ensure_global_target(self, type_name: str, path: str) -> str: self._broadcast_parameter_creation(global_path, entry.default, entry.unit) return global_path + # ------------------------------------------------------------------ + # Type Locks (plan decision D17, task 3.2) + # + # A Type Lock is a rule on a Type entry naming a Target: declaring it + # with ``lock_type_parameter`` stores the Target on the entry (the + # default Target is the Globals parameter ``_globals..``, + # created on demand through ``_ensure_global_target``) and puts an + # ordinary, locked Lock on the entry's parameter in every current + # Instance. Instance parameters that carry a Lock on another Target + # are skipped with a warning and returned; ``unlock_type_parameter`` + # removes only the rule, and the Locks it created stay until they are + # removed individually. Every new Instance — created by + # ``add_instance``, or completed by ``add_type_parameter`` / + # ``add_nested_type`` — gets the existing Type Locks of its Type at + # creation, pre-existing parameters included. The Locks themselves are + # applied through the ordinary Lock API, so each state change emits + # exactly one ``pm-lock-update`` (D10); the declaration and the removal + # each emit one ``pm-type-update`` for the edited Type (D22). + # ------------------------------------------------------------------ + + def _classify_lock_application( + self, param_path: str, target_full: str + ) -> Tuple[str, "str | None"]: + """Classify what applying the Target ``target_full`` (the full + dotted form) to the parameter at ``param_path`` would do: + ``"lock"`` (no Lock present), ``"relock"`` (an unlocked Lock + remembering the same Target), ``"none"`` (already locked to the + same Target) or ``"skip"`` together with the Target the + parameter's Lock points at — a Lock on another Target is left + alone (D17).""" + param = self.parameter(param_path) + lock = getattr(param, "lock", None) + if lock is None: + return "lock", None + if lock.target == target_full: + return ("none" if lock.locked else "relock"), None + return "skip", lock.target + + def lock_type_parameter( + self, type_name: str, path: str, target: str | None = None + ) -> List[str]: + """Declare the Type Lock of the entry ``path`` of the Type + ``type_name`` (D17): the Target is stored on the entry — the + parameter at ``target`` when given, the Globals parameter + ``_globals..`` otherwise — and an ordinary, + locked Lock on that Target is put on the entry's parameter in + every current Instance of the Type. The default Globals Target is + created on demand with the entry's default value and unit, through + :meth:`_ensure_global_target`. + + Instance parameters that already carry a Lock on another Target + are skipped: they are left untouched, named together with their + Target in one ``logger.warning``, and returned as a list of dotted + paths relative to this Parameter Manager (empty when nothing was + skipped). A parameter already locked to the same Target stays as + it is; an unlocked Lock remembering the same Target is locked + again. Declaring the Type Lock again therefore re-applies it to + everyone ("lock all"); declaring it with a different Target stores + the new one, and the Followers still locked to the old Target + count as skipped. + + Raises ``ValueError`` — before anything is touched — naming every + offending path when no such Type exists, when ``path`` is not an + entry of the Type itself (naming the Type that defines it when the + path only reaches the effective parameter set through a Nested + Type; own entries only, like + :meth:`set_type_parameter_default`), when an explicit ``target`` + does not exist as a parameter of this Parameter Manager, and when + locking one of the Instance parameters would be a self-lock, close + a cycle (walking Targets regardless of locked/unlocked state, D7) + or hit a parameter that cannot carry a Lock. + + On success it emits the Globals parameter's ``parameter-creation`` + (when created), then one ``pm-lock-update`` per Lock it created or + relocked — nothing for skipped or already-locked parameters — and + finally one ``pm-type-update`` for the edited Type (D10, D22); the + Types nesting it are not named, since their effective parameter + set carries units and defining Types, not Targets. A failed + validation emits nothing. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the Type's own entry. + :param target: Path of the Target, relative to this Parameter + Manager; the default Globals Target when ``None``. + :return: The skipped Instance parameter paths, in tree order. + """ + # validate-then-mutate: every check below runs before the entry or + # any Lock is touched (rule 3) + entry = self._require_type_entry(type_name, path) + if target is not None: + # the explicit Target must be a parameter of this Parameter + # Manager, resolved like lock() resolves it (D8) + self._resolve_param(target) + target_relative = target + else: + target_relative = f"_globals.{type_name}.{path}" + target_full = self._full_path(target_relative) + # classify every current Instance's parameter at the entry path; an + # Instance always carries the parameter (D12) + applications: List[Tuple[str, str]] = [] + skipped: List[Tuple[str, str]] = [] + offenders: List[str] = [] + for instance_path in self.instances_of(type_name): + param_path = f"{instance_path}.{path}" + action, locked_to = self._classify_lock_application( + param_path, target_full + ) + if action == "skip": + skipped.append((param_path, locked_to)) + continue + if action == "none": + continue + follower_full = self._full_path(param_path) + if not isinstance(self.parameter(param_path), ManagedParameter): + offenders.append(f"{follower_full} cannot carry a Lock") + continue + try: + self._check_lock_allowed(follower_full, target_full) + except ValueError as exc: + offenders.append(str(exc)) + continue + applications.append((param_path, action)) + if offenders: + raise ValueError( + f"cannot lock the Instance parameters of Type '{type_name}' " + f"entry '{path}' to {target_full}: " + "; ".join(offenders) + ) + # the default Target is the Globals parameter, created on demand; + # its parameter-creation Broadcast goes out before the Lock updates + if target is None: + self._ensure_global_target(type_name, path) + entry.target = target_full + # apply the Locks in tree order; each state change emits exactly + # one pm-lock-update through lock()/relock() (D10) + for param_path, action in applications: + if action == "lock": + self.lock(param_path, target_relative) + else: + self.relock(param_path) + if skipped: + described = ", ".join( + f"'{param_path}' (locked to {locked_to})" + for param_path, locked_to in skipped + ) + logger.warning( + f"Type Lock of Type '{type_name}' entry '{path}' to " + f"{target_full}: skipped Instance parameter(s) {described}, " + "which carry a Lock on another Target" + ) + # one pm-type-update for the edited Type, after the Lock updates; + # the Types nesting it are not named — their effective parameter + # set carries units and defining Types, not Targets (D22) + self._broadcast_type_update(type_name) + return [param_path for param_path, _ in skipped] + + def unlock_type_parameter(self, type_name: str, path: str) -> None: + """Remove the Type Lock of the entry ``path`` of the Type + ``type_name`` (D17): only the rule goes — the entry's stored Target + is cleared and one ``pm-type-update`` Broadcast naming the Type + goes out — while the Locks the declaration put on the Instance + parameters stay until they are removed individually with + :meth:`remove_lock`. Instances that stop matching keep their + Locks either way. + + Raises ``ValueError`` naming the Type and the path when no such + Type exists or ``path`` is not an entry of the Type itself; nothing + is changed then. An entry that carries no Type Lock is a no-op + logged at INFO level that emits nothing (like :meth:`unlock` and + :meth:`relock`). + + :param type_name: Name of the Type. + :param path: Relative parameter path of the Type's own entry. + """ + entry = self._require_type_entry(type_name, path) + if entry.target is None: + logger.info( + f"the entry '{path}' of Type '{type_name}' carries no Type " + "Lock; nothing to do" + ) + return + entry.target = None + self._broadcast_type_update(type_name) + + def _apply_type_locks_to_new_instances( + self, lock_types: List[str], instances_before: Dict[str, List[str]] + ) -> None: + """Apply the existing Type Locks of the Types ``lock_types`` to the + submodules that an edit just turned into new Instances (D17): a + Parameter Group that is an Instance after the edit but was not one + before gets a locked Lock on the entry's Target for every entry of + the Type's effective parameter set that carries one — a parameter + the edit kept (D14) included, since a Lock changes neither its own + value nor its unit. + + Per parameter an unlocked Lock remembering the same Target is + locked again, an already locked one is left as it is, and a Lock + on another Target is skipped. A Target that no longer exists + (possible until task 3.3 clears the stored Target on deletion), a + parameter that cannot carry a Lock and one whose Lock would close + a cycle are skipped too. Every skip is collected into one + ``logger.warning`` naming the Type, the entry path, the skipped + parameter path and the reason; the edit itself still succeeds and + the calling method keeps returning ``None``. Submodules that were + Instances before the edit are untouched: only + :meth:`lock_type_parameter` re-applies a Type Lock to everyone. + + Each applied Lock emits exactly one ``pm-lock-update`` through + :meth:`lock` / :meth:`relock` (D10); the caller runs this after + the ``parameter-creation`` Broadcasts and before the + ``pm-type-update`` Broadcasts. + + :param lock_types: Names of the Types whose Type Locks are applied, + the edited Type and the Types nesting it (and, for + :meth:`add_nested_type`, the Nested Type and the Types nesting + it). + :param instances_before: The Instances of every Type in + ``lock_types``, computed before the edit (see + :meth:`_instances_before_edit`). + """ + applications: List[Tuple[str, str, bool]] = [] + skipped: List[Tuple[str, str, str, str]] = [] + seen: set = set() + for type_name in lock_types: + locked_entries = { + entry_path: entry + for entry_path, entry in self._effective_entries(type_name).items() + if entry.target is not None + } + if not locked_entries: + continue + for instance_path in self.instances_of(type_name): + if instance_path in instances_before.get(type_name, []): + # an Instance before the edit: only lock_type_parameter + # re-applies a Type Lock to everyone + continue + for entry_path, entry in locked_entries.items(): + param_path = f"{instance_path}.{entry_path}" + if param_path in seen: + # the same defining entry reaches the parameter + # through several Types of the closure + continue + seen.add(param_path) + if self._param_by_full_path(entry.target) is None: + skipped.append( + ( + type_name, + entry_path, + param_path, + f"the stored Target {entry.target} does " + "not exist", + ) + ) + continue + action, locked_to = self._classify_lock_application( + param_path, entry.target + ) + if action == "skip": + skipped.append( + ( + type_name, + entry_path, + param_path, + "it carries a Lock on another Target " + f"({locked_to})", + ) + ) + continue + if action == "none": + continue + param = self.parameter(param_path) + follower_full = self._full_path(param_path) + if not isinstance(param, ManagedParameter): + skipped.append( + ( + type_name, + entry_path, + param_path, + f"{follower_full} cannot carry a Lock", + ) + ) + continue + try: + self._check_lock_allowed(follower_full, entry.target) + except ValueError as exc: + skipped.append( + (type_name, entry_path, param_path, str(exc)) + ) + continue + applications.append( + ( + param_path, + entry.target[len(self.name) + 1:], + action == "relock", + ) + ) + for param_path, target_relative, is_relock in applications: + if is_relock: + self.relock(param_path) + else: + self.lock(param_path, target_relative) + if skipped: + described = "; ".join( + f"'{param_path}' (entry '{entry_path}' of Type " + f"'{type_name}') {reason}" + for type_name, entry_path, param_path, reason in skipped + ) + logger.warning( + "skipped Instance parameter(s) while applying the Type " + f"Locks to new Instances: {described}" + ) + @staticmethod def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": """Create a new ParameterManager instance from a paramDict. diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index e23b215..7c562a6 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -2,8 +2,10 @@ duck-typed Instance matching (plan task 2.2), the Type edits with Instance side effects (plan task 2.3), ``add_instance`` (plan task 2.4), the ``pm-type-update`` and side-effect creation Broadcasts (plan task -2.5) and the Globals rules with the ``_ensure_global_target`` helper -(plan task 3.1). +2.5), the Globals rules with the ``_ensure_global_target`` helper (plan +task 3.1) and the Type Locks ``lock_type_parameter`` / +``unlock_type_parameter`` with their application to new Instances (plan +task 3.2). The definition and editing methods are exercised through the public API: ``add_type`` / ``add_type_parameter`` / ``add_nested_type`` and friends. @@ -51,15 +53,41 @@ byte-identical — and that a Globals parameter is otherwise ordinary (set, read, Target of a Lock). The proxy part exercises the ``add_parameter`` refusal over the wire. +The Type Lock part (3.2) checks that ``lock_type_parameter`` locks every +current Instance's parameter to the Target — the default Globals one, +created on demand with the entry's default and unit, or an explicit +ordinary parameter — that the skipped Followers carrying a Lock on +another Target are returned and named in one warning, that re-declaring +re-applies ("lock all") and a different Target skips the old Followers, +that ``unlock_type_parameter`` removes only the rule while every Lock +stays, that an Instance falling out keeps its Locks, that +``add_instance`` locks the new Instance's parameters at the locked +entries (a kept parameter included, with its own value and unit +untouched) and skips the ones it cannot lock with one warning, that +``add_nested_type`` applies the Nested Type's Type Lock under the +submodule (a pre-existing parameter included) while ``add_type_parameter`` +applies none (its fresh entries carry no Target), and the refusals +(unknown Type, non-own entry, missing explicit Target, self-lock, cycle, +parameters that cannot carry a Lock) each leaving the tree, the Locks and +the registry byte-identical. The Broadcast part checks the order +parameter-creation, pm-lock-updates, pm-type-update for a declaration, +the single pm-type-update of a removal and of an all-locked re-declare, +and that refused calls emit nothing. The proxy part exercises both +methods over the wire (the skipped list round-tripping, the full-form +Target in ``get_type``, a locked Instance parameter pulling the Globals +value) and a SubClient receiving the declaration Broadcasts made by a +second client. """ import copy +import logging import re import pytest from instrumentserver.blueprints import ( PARAMETER_CREATION, + PM_LOCK_UPDATE, PM_TYPE_UPDATE, ParameterBroadcastBluePrint, PMLockBluePrint, @@ -2035,6 +2063,562 @@ def test_a_globals_parameter_is_an_ordinary_parameter(pm): assert pm.followers_of(path) == ["q01.IF"] +# --------------------------------------------------------------------------- +# Type Locks: lock_type_parameter / unlock_type_parameter (plan task 3.2, D17) +# +# A Type Lock is a rule on a Type entry naming a Target. Declaring it +# stores the Target on the entry (the default is the Globals parameter +# ``_globals..``, created on demand) and puts an ordinary, +# locked Lock on the entry's parameter in every current Instance; +# Followers that carry a Lock on another Target are skipped with one +# warning and returned. Removing the Type Lock removes only the rule; +# every new Instance gets the existing Type Locks of its Type at +# creation. +# --------------------------------------------------------------------------- + + +def put_qubit_instances(pm): + """The Type ``qubit`` (entries IF and octave_gain) with the Instances + q01 and q02 carrying the whole shape.""" + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("q02.IF", initial_value=6e9, unit="Hz") + pm.add_parameter("q02.octave_gain", initial_value=11, unit="dB") + + +def lock_state(pm): + """Everything a refused Type Lock call must leave byte-identical: the + parameter tree, every Lock and the Type registry.""" + return ( + sorted(pm.list()), + pm.list_locks(), + copy.deepcopy(pm._types), + ) + + +def test_lock_type_parameter_locks_every_current_instance_parameter(pm): + put_qubit_instances(pm) + + skipped = pm.lock_type_parameter("qubit", "IF") + + assert skipped == [] + # every Instance parameter is locked to the default Globals Target + for inst in ("q01", "q02"): + assert pm.get_lock(f"{inst}.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + # the Followers pull the Target's value and refuse set + pm.set("_globals.qubit.IF", 7e9) + assert pm.get("q01.IF") == 7e9 + assert pm.get("q02.IF") == 7e9 + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.IF is locked to " + "parameter_manager._globals.qubit.IF" + ), + ): + pm.set("q01.IF", 1) + # the entry carries the Target in the full form; the other entry is + # untouched + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + assert pm.get_type("qubit").parameters["octave_gain"]["target"] is None + + +def test_lock_type_parameter_creates_the_default_globals_target(pm): + put_qubit_instances(pm) + + pm.lock_type_parameter("qubit", "IF") + + # the default Target _globals.. with the entry's default + # and unit + assert pm.has_param("_globals.qubit.IF") + assert pm.get("_globals.qubit.IF") == 5e9 + assert pm.parameter("_globals.qubit.IF").unit == "Hz" + assert isinstance(pm.parameter("_globals.qubit.IF"), ManagedParameter) + + +def test_lock_type_parameter_skips_a_lock_on_another_target_with_a_warning( + pm, caplog +): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.lock("q01.IF", "q00.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + skipped = pm.lock_type_parameter("qubit", "IF") + + # the skipped path is returned, in tree order + assert skipped == ["q01.IF"] + # one warning naming the Type, the entry path, the skipped path and + # its Target + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'qubit'" in message + assert "'IF'" in message + assert "'q01.IF'" in message + assert "parameter_manager.q00.IF" in message + # the skipped Lock is untouched and still pulls its own Target + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + assert pm.get("q01.IF") == 1e9 + # the other Instance is locked + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + + +def test_lock_type_parameter_without_skips_warns_nothing(pm, caplog): + put_qubit_instances(pm) + caplog.clear() + + with caplog.at_level(logging.WARNING): + assert pm.lock_type_parameter("qubit", "IF") == [] + + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert warnings_ == [] + + +def test_add_instance_applies_the_existing_type_lock_to_the_new_instance(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.add_instance("qubit", "q03") + + assert pm.get_lock("q03.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q03.IF") == pm.get("_globals.qubit.IF") + + +def test_add_instance_locks_a_kept_parameter_and_keeps_its_own_value_and_unit(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # q05 carries IF with its own value already, but lacks octave_gain: + # not an Instance yet + pm.add_parameter("q05.IF", initial_value=4e9, unit="Hz") + + pm.add_instance("qubit", "q05") + + # the kept parameter is locked like a created one; its own value and + # unit are untouched (D14 keeps those, D17 adds the Lock) + assert pm.get_lock("q05.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.parameter("q05.IF").own_value() == 4e9 + assert pm.parameter("q05.IF").unit == "Hz" + assert pm.instances_of("qubit") == ["q01", "q02", "q05"] + + +def test_add_instance_skips_a_kept_parameter_locked_to_another_target(pm, caplog): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q06.IF", initial_value=4e9, unit="Hz") + pm.lock("q06.IF", "q00.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q06") + + # the creation itself succeeded and q06 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q06"] + assert pm.get("q06.octave_gain") == 10 + # the kept parameter's Lock on another Target is untouched + assert pm.get_lock("q06.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + assert pm.get("q06.IF") == 1e9 + # one warning names the Type, the entry path, the skipped path and + # its Target + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'qubit'" in message + assert "'IF'" in message + assert "'q06.IF'" in message + assert "parameter_manager.q00.IF" in message + + +def test_add_instance_skips_the_type_lock_when_the_target_is_gone(pm, caplog): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # the Globals Target is removed; until task 3.3 the entry keeps the + # stored Target, and the application must skip instead of raising + pm.remove_parameter("_globals.qubit.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q07") + + # the creation itself still succeeds; the created parameter carries + # no Lock + assert pm.instances_of("qubit") == ["q01", "q02", "q07"] + assert pm.get_lock("q07.IF") is None + assert pm.get("q07.IF") == 5e9 + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q07.IF'" in message + assert "does not exist" in message + + +def test_the_creation_methods_leave_an_existing_instance_lock_state_untouched(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q01.IF") # unlocked individually + + pm.add_instance("qubit", "q08") + + # q01 was an Instance before the edit: its unlocked Lock stays + # unlocked; only lock_type_parameter re-applies to everyone + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=False + ) + # the new Instance is locked + assert pm.get_lock("q08.IF").locked is True + + +def test_add_type_parameter_applies_no_type_locks(pm): + # under the edit loops the parameters are created only under Instances + # found before the edit, so no submodule becomes a new Instance and no + # Type Lock is applied: the fresh entry carries no Target, and the + # Instances that existed keep their Lock state + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q01.IF") # an individually unlocked Follower stays unlocked + locks_before = pm.list_locks() + + pm.add_type_parameter("qubit", "extra", default=1, unit="V") + + # the created parameters carry no Lock + assert pm.get_lock("q01.extra") is None + assert pm.get_lock("q02.extra") is None + assert pm.list_locks() == locks_before + + # the same through a nesting Type: readout's fresh entry window is + # created under qubit's Instance, with no Lock + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_nested_type("qubit", "readout", "readout") + pm.lock_type_parameter("readout", "IF") + locks_before = pm.list_locks() + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + assert pm.get_lock("q01.readout.window") is None + assert pm.list_locks() == locks_before + + +def test_add_nested_type_applies_the_nested_type_lock_under_the_submodule(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.lock_type_parameter("readout", "IF") + + pm.add_nested_type("qubit", "readout", "readout") + + # the created readout.IF is locked to readout's Globals Target + assert pm.get_lock("q01.readout.IF") == PMLockBluePrint( + target="parameter_manager._globals.readout.IF", locked=True + ) + assert pm.get("q01.readout.IF") == pm.get("_globals.readout.IF") + + +def test_add_nested_type_locks_a_pre_existing_parameter_when_it_completes(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + # q01.readout carries only IF: not an Instance of readout yet, so the + # declaration locks nobody + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.lock_type_parameter("readout", "IF") + assert pm.get_lock("q01.readout.IF") is None + + pm.add_nested_type("qubit", "readout", "readout") + + # the edit creates the missing window, completing q01.readout into a + # new Instance of readout: its PRE-EXISTING IF gets the Type Lock + assert pm.get_lock("q01.readout.IF") == PMLockBluePrint( + target="parameter_manager._globals.readout.IF", locked=True + ) + # the entry without a Type Lock stays unlocked + assert pm.get_lock("q01.readout.window") is None + + +def test_unlock_type_parameter_leaves_every_lock_in_place(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + locks_before = pm.list_locks() + + pm.unlock_type_parameter("qubit", "IF") + + # only the Type Lock goes: the entry's Target is cleared ... + assert pm.get_type("qubit").parameters["IF"]["target"] is None + # ... and every Lock stays, locked as it was + assert pm.list_locks() == locks_before + assert pm.get("q01.IF") == pm.get("_globals.qubit.IF") + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.IF is locked to " + "parameter_manager._globals.qubit.IF" + ), + ): + pm.set("q01.IF", 1) + + +def test_unlock_type_parameter_without_a_type_lock_is_a_logged_no_op(pm, caplog): + put_qubit_instances(pm) + received = [] + pm.add_broadcast_sink(received.append) + caplog.clear() + + with caplog.at_level(logging.INFO): + pm.unlock_type_parameter("qubit", "IF") + + assert pm.get_type("qubit").parameters["IF"]["target"] is None + # the no-op emits nothing + assert received == [] + records = [ + r + for r in caplog.records + if r.levelno == logging.INFO and r.name == "instrumentserver.params" + ] + assert len(records) == 1 + assert "carries no Type Lock" in records[0].getMessage() + + +def test_an_instance_falling_out_keeps_its_locks(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_parameter("q01.octave_gain") # q01 stops matching + + assert pm.instances_of("qubit") == ["q02"] + # the Lock stays on the parameter that fell out + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q01.IF") == pm.get("_globals.qubit.IF") + + +def test_re_declaring_the_type_lock_re_applies_it(pm, caplog): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.lock_type_parameter("qubit", "IF") + # q01 is re-targeted to another Target, q02 is unlocked individually + pm.lock("q01.IF", "q00.IF") + pm.unlock("q02.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + skipped = pm.lock_type_parameter("qubit", "IF") + + # the Follower on another Target is skipped again, with the warning + assert skipped == ["q01.IF"] + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + # the individually unlocked Follower is locked again ("lock all") + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q02.IF") == pm.get("_globals.qubit.IF") + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + assert "'q01.IF'" in warnings_[0].getMessage() + + +def test_re_declaring_with_a_different_target_skips_the_old_followers(pm, caplog): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.lock_type_parameter("qubit", "IF") # everyone locked to the Globals + pm.remove_lock("q02.IF") # no Lock at all + caplog.clear() + + with caplog.at_level(logging.WARNING): + skipped = pm.lock_type_parameter("qubit", "IF", target="q00.IF") + + # the Follower still locked to the old Target is skipped + assert skipped == ["q01.IF"] + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + # the parameter without a Lock is locked to the new Target + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + assert pm.get("q02.IF") == 1e9 + # the entry carries the new Target + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager.q00.IF" + ) + + +def test_lock_type_parameter_with_an_explicit_ordinary_target(pm): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + + skipped = pm.lock_type_parameter("qubit", "IF", target="q00.IF") + + assert skipped == [] + # no Globals parameter is created for an explicit Target + assert not pm.has_param("_globals.qubit.IF") + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager.q00.IF" + ) + for inst in ("q01", "q02"): + assert pm.get_lock(f"{inst}.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + pm.set("q00.IF", 2e9) + assert pm.get("q01.IF") == 2e9 + + +def test_lock_type_parameter_refuses_an_unknown_type(pm): + put_qubit_instances(pm) + before = lock_state(pm) + + with pytest.raises(ValueError, match="no Type named 'nope' exists"): + pm.lock_type_parameter("nope", "IF") + with pytest.raises(ValueError, match="no Type named 'nope' exists"): + pm.unlock_type_parameter("nope", "IF") + + assert lock_state(pm) == before + + +def test_lock_type_parameter_refuses_a_path_that_is_not_an_own_entry(pm): + put_three_tier_registry(pm) + put_three_tier_tree(pm) + before = lock_state(pm) + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is not an entry of Type 'qubit' " + "itself: it is only in the effective set through the entry of " + "Type 'readout'" + ), + ): + pm.lock_type_parameter("qubit", "readout.IF") + with pytest.raises( + ValueError, + match=re.escape("parameter path 'nope' is not an entry of Type 'qubit'"), + ): + pm.lock_type_parameter("qubit", "nope") + # unlock_type_parameter refuses the same way + with pytest.raises( + ValueError, + match=re.escape("'readout.IF' is not an entry of Type 'qubit' itself"), + ): + pm.unlock_type_parameter("qubit", "readout.IF") + + assert lock_state(pm) == before + + +def test_lock_type_parameter_refuses_a_missing_explicit_target(pm): + put_qubit_instances(pm) + before = lock_state(pm) + + with pytest.raises(ValueError, match="Parameter 'nope.IF' does not exist"): + pm.lock_type_parameter("qubit", "IF", target="nope.IF") + + # nothing was created and nobody was locked + assert lock_state(pm) == before + assert not pm.has_param("_globals.qubit.IF") + + +def test_lock_type_parameter_refuses_a_self_lock_naming_the_path(pm): + put_qubit_instances(pm) + before = lock_state(pm) + + # the explicit Target is the entry's own parameter in one of the + # Instances: its own Instance is a self-lock + with pytest.raises( + ValueError, + match=re.escape("cannot lock parameter_manager.q01.IF to itself"), + ): + pm.lock_type_parameter("qubit", "IF", target="q01.IF") + + assert lock_state(pm) == before + assert not pm.has_param("_globals.qubit.IF") + + +def test_lock_type_parameter_refuses_a_cycle_naming_the_path(pm): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + # the Target is a Follower of the Instance parameter q01.IF + pm.lock("q00.IF", "q01.IF") + before = lock_state(pm) + + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.IF to " + "parameter_manager.q00.IF: cycle in Lock targets: " + "parameter_manager.q00.IF -> parameter_manager.q01.IF" + ), + ): + pm.lock_type_parameter("qubit", "IF", target="q00.IF") + + assert lock_state(pm) == before + + +def test_lock_type_parameter_names_every_offending_path(pm): + # both Instances carry a plain qcodes Parameter at the entry path: + # neither can carry a Lock, and one error names both (rule 3) + put_qubit_instances(pm) + pm.remove_parameter("q01.IF") + pm.remove_parameter("q02.IF") + pm.q01._add_own_parameter("IF", set_cmd=None, unit="Hz") + pm.q02._add_own_parameter("IF", set_cmd=None, unit="Hz") + assert pm.instances_of("qubit") == ["q01", "q02"] + before = lock_state(pm) + + with pytest.raises(ValueError) as excinfo: + pm.lock_type_parameter("qubit", "IF") + + message = str(excinfo.value) + assert "parameter_manager.q01.IF cannot carry a Lock" in message + assert "parameter_manager.q02.IF cannot carry a Lock" in message + assert lock_state(pm) == before + assert not pm.has_param("_globals.qubit.IF") + + # --------------------------------------------------------------------------- # pm-type-update and side-effect creation Broadcasts (plan task 2.5, D22) # @@ -2458,6 +3042,134 @@ def test_type_broadcast_payloads_are_snapshots_of_their_time(pm_with_sink): assert list(third.parameters) == ["IF", "octave_gain"] +# --------------------------------------------------------------------------- +# Type Lock Broadcasts (plan task 3.2, D10/D17/D22) +# +# A declaration emits the Globals parameter-creation (when created), then +# one pm-lock-update per Lock it created or relocked — nothing for skipped +# or already-locked parameters — then one pm-type-update for the edited +# Type. A removal emits exactly one pm-type-update; the no-op removal +# emits nothing. add_instance with an existing Type Lock emits its +# parameter-creations first, then the pm-lock-updates. A refused call +# emits nothing. +# --------------------------------------------------------------------------- + + +def test_lock_type_parameter_emits_creation_lock_updates_then_type_update( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + received.clear() + + pm.lock_type_parameter("qubit", "IF") + + assert len(received) == 4 + creation, first_lock, second_lock, type_update = received + assert creation.name == "parameter_manager._globals.qubit.IF" + assert creation.action == PARAMETER_CREATION + assert creation.value == 5e9 + assert creation.unit == "Hz" + assert first_lock.name == "parameter_manager.q01.IF" + assert first_lock.action == PM_LOCK_UPDATE + assert first_lock.value == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert second_lock.name == "parameter_manager.q02.IF" + assert second_lock.action == PM_LOCK_UPDATE + assert second_lock.value == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert type_update.name == "parameter_manager.qubit" + assert type_update.action == PM_TYPE_UPDATE + assert type_update.value.parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + + +def test_re_declaring_an_all_locked_type_lock_emits_only_the_type_update( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.lock_type_parameter("qubit", "IF") + + # every Instance parameter is already locked to the same Target: no + # pm-lock-update, and the Globals parameter exists already + assert len(received) == 1 + assert received[0].name == "parameter_manager.qubit" + assert received[0].action == PM_TYPE_UPDATE + + +def test_unlock_type_parameter_emits_exactly_one_pm_type_update(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.unlock_type_parameter("qubit", "IF") + + # one pm-type-update and no pm-lock-update: the Locks stay + assert len(received) == 1 + update = received[0] + assert update.name == "parameter_manager.qubit" + assert update.action == PM_TYPE_UPDATE + assert update.value.parameters["IF"]["target"] is None + + received.clear() + pm.unlock_type_parameter("qubit", "IF") # no Type Lock any more: no-op + assert received == [] + + +def test_add_instance_with_a_type_lock_emits_creations_then_lock_updates( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.add_instance("qubit", "q03") + + # the parameter-creations first, then the pm-lock-update of the + # applied Type Lock; add_instance edits no Type, so no pm-type-update + assert len(received) == 3 + first, second, lock_update = received + assert first.name == "parameter_manager.q03.IF" + assert first.action == PARAMETER_CREATION + assert second.name == "parameter_manager.q03.octave_gain" + assert second.action == PARAMETER_CREATION + assert lock_update.name == "parameter_manager.q03.IF" + assert lock_update.action == PM_LOCK_UPDATE + assert lock_update.value == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + + +def test_failed_type_lock_calls_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + received.clear() + + with pytest.raises(ValueError): + pm.lock_type_parameter("nope", "IF") + with pytest.raises(ValueError): + pm.lock_type_parameter("qubit", "nope") + with pytest.raises(ValueError): + pm.lock_type_parameter("qubit", "IF", target="nope.IF") + with pytest.raises(ValueError): + pm.lock_type_parameter("qubit", "IF", target="q01.IF") # self-lock + with pytest.raises(ValueError): + pm.unlock_type_parameter("qubit", "nope") + + assert received == [] + assert not pm.has_param("_globals.qubit.IF") + assert pm.list_locks() == {} + + # --------------------------------------------------------------------------- # Type API and Broadcasts through a client proxy against a live Server # (plan task 2.5) @@ -2651,3 +3363,131 @@ def test_add_parameter_refusal_over_the_wire(param_manager): # nothing was created over the wire either assert sorted(params.list()) == before + + +# --------------------------------------------------------------------------- +# Type Locks through a client proxy against a live Server (plan task 3.2) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3), so a Type Lock declaration over the wire also emits its +# Broadcasts on the PUB socket. The server-side Parameter Manager is +# shared by all tests of this module, so every test removes the parameters +# and Types it created again — the Globals Targets included. +# --------------------------------------------------------------------------- + +PROXY_LOCK_TYPE = "ptl_qubit" +PROXY_LOCK_INSTANCES = ("ptl_q01", "ptl_q02") + + +def _cleanup_proxy_lock_types(params): + """Remove every parameter (the Globals Targets included) and the Type + the Type Lock proxy tests create, so the module's shared server-side + Parameter Manager starts each test clean.""" + for path in list(params.list()): + top = path.split(".")[0] + if top.startswith("ptl_") or top == "_globals": + params.remove_parameter(path) + if PROXY_LOCK_TYPE in params.list_types(): + params.remove_type(PROXY_LOCK_TYPE) + + +def test_lock_type_parameter_round_trips_over_the_wire(param_manager): + cli, params = param_manager + _cleanup_proxy_lock_types(params) + try: + params.add_type(PROXY_LOCK_TYPE) + params.add_type_parameter(PROXY_LOCK_TYPE, "IF", default=5e9, unit="Hz") + params.add_type_parameter( + PROXY_LOCK_TYPE, "octave_gain", default=10, unit="dB" + ) + for name in PROXY_LOCK_INSTANCES: + params.add_instance(PROXY_LOCK_TYPE, name) + params.update() + + # nothing is skipped: the return value is an empty list + assert params.lock_type_parameter(PROXY_LOCK_TYPE, "IF") == [] + + # get_type deserialises with the full-form Target + bp = params.get_type(PROXY_LOCK_TYPE) + assert isinstance(bp, PMTypeBluePrint) + assert bp.parameters["IF"]["target"] == ( + f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF" + ) + + # a locked Instance parameter pulls the Globals Target's value; + # the Target is set through the server-side Parameter Group's set + # (the client proxy's own set is qcodes' local, deprecated one) + cli.call( + "parameter_manager.set", f"_globals.{PROXY_LOCK_TYPE}.IF", 7e9 + ) + assert getattr(params, PROXY_LOCK_INSTANCES[0]).IF() == 7e9 + + # a Follower locked to another Target comes back as the skipped + # list over the wire + params.add_parameter("ptl_target.IF", initial_value=1e9, unit="Hz") + params.lock(f"{PROXY_LOCK_INSTANCES[1]}.IF", "ptl_target.IF") + skipped = params.lock_type_parameter(PROXY_LOCK_TYPE, "IF") + assert skipped == [f"{PROXY_LOCK_INSTANCES[1]}.IF"] + + # the removal works over the wire too and clears the Target + params.unlock_type_parameter(PROXY_LOCK_TYPE, "IF") + assert params.get_type(PROXY_LOCK_TYPE).parameters["IF"]["target"] is None + # the Locks it created stay + assert params.get_lock(f"{PROXY_LOCK_INSTANCES[0]}.IF").locked is True + finally: + _cleanup_proxy_lock_types(params) + + +def test_subclient_receives_the_type_lock_broadcasts_from_a_second_client( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _cleanup_proxy_lock_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + second_params.add_type(PROXY_LOCK_TYPE) + second_params.add_type_parameter(PROXY_LOCK_TYPE, "IF", default=5e9, unit="Hz") + second_params.add_type_parameter( + PROXY_LOCK_TYPE, "octave_gain", default=10, unit="dB" + ) + for name in PROXY_LOCK_INSTANCES: + second_params.add_instance(PROXY_LOCK_TYPE, name) + + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + second_params.lock_type_parameter(PROXY_LOCK_TYPE, "IF") + wait_for_broadcasts(received, n=4) + + # the Globals parameter-creation, one pm-lock-update per + # locked Instance parameter, then the pm-type-update + assert [bp.action for bp in received] == [ + PARAMETER_CREATION, + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == ( + f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF" + ) + assert received[1].name == f"parameter_manager.{PROXY_LOCK_INSTANCES[0]}.IF" + assert isinstance(received[1].value, PMLockBluePrint) + assert received[1].value == PMLockBluePrint( + target=f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF", + locked=True, + ) + assert received[2].name == f"parameter_manager.{PROXY_LOCK_INSTANCES[1]}.IF" + assert received[3].name == f"parameter_manager.{PROXY_LOCK_TYPE}" + assert isinstance(received[3].value, PMTypeBluePrint) + assert received[3].value.parameters["IF"]["target"] == ( + f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF" + ) + + second_params.unlock_type_parameter(PROXY_LOCK_TYPE, "IF") + wait_for_broadcasts(received, n=5) + assert received[4].action == PM_TYPE_UPDATE + assert received[4].value.parameters["IF"]["target"] is None + finally: + second_cli.disconnect() + _cleanup_proxy_lock_types(params) From efbafc611ec57ff64dc03d4a8d207bb3942586b9 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 09:02:34 -0500 Subject: [PATCH 055/107] 3.2: fix from review round 1: never raise from the Type Lock application batch, and cover the creation-path skip and relock branches --- src/instrumentserver/params.py | 37 +++++-- test/pytest/test_pm_types.py | 172 +++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 8 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index fa6cd9e..b8f3676 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -2177,8 +2177,13 @@ def _apply_type_locks_to_new_instances( locked again, an already locked one is left as it is, and a Lock on another Target is skipped. A Target that no longer exists (possible until task 3.3 clears the stored Target on deletion), a - parameter that cannot carry a Lock and one whose Lock would close - a cycle are skipped too. Every skip is collected into one + parameter that cannot carry a Lock and one whose Lock application + would be a self-lock or close a cycle are skipped too — including + a cycle that another application of the same batch creates, since + every application is validated again against the state the earlier + ones left when it runs. No Lock application raises: every + ``ValueError`` the Lock API raises for one parameter is recorded + as that parameter's skip reason. Every skip is collected into one ``logger.warning`` naming the Type, the entry path, the skipped parameter path and the reason; the edit itself still succeeds and the calling method keeps returning ``None``. Submodules that were @@ -2198,7 +2203,7 @@ def _apply_type_locks_to_new_instances( ``lock_types``, computed before the edit (see :meth:`_instances_before_edit`). """ - applications: List[Tuple[str, str, bool]] = [] + applications: List[Tuple[str, str, str, str, bool]] = [] skipped: List[Tuple[str, str, str, str]] = [] seen: set = set() for type_name in lock_types: @@ -2269,16 +2274,32 @@ def _apply_type_locks_to_new_instances( continue applications.append( ( + type_name, + entry_path, param_path, entry.target[len(self.name) + 1:], action == "relock", ) ) - for param_path, target_relative, is_relock in applications: - if is_relock: - self.relock(param_path) - else: - self.lock(param_path, target_relative) + for ( + type_name, + entry_path, + param_path, + target_relative, + is_relock, + ) in applications: + try: + if is_relock: + self.relock(param_path) + else: + self.lock(param_path, target_relative) + except ValueError as exc: + # the earlier applications of this batch changed the Lock + # state the up-front classification validated against: the + # Lock API refused this one, so it is skipped like any + # other application that cannot run — the edit itself + # still succeeds and no exception escapes + skipped.append((type_name, entry_path, param_path, str(exc))) if skipped: described = "; ".join( f"'{param_path}' (entry '{entry_path}' of Type " diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 7c562a6..7e271f0 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -2143,6 +2143,27 @@ def test_lock_type_parameter_creates_the_default_globals_target(pm): assert isinstance(pm.parameter("_globals.qubit.IF"), ManagedParameter) +def test_lock_type_parameter_with_no_instances_stores_the_rule(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + skipped = pm.lock_type_parameter("qubit", "IF") + + # no current Instances, so nothing is locked — but the rule is stored + # and the default Globals Target is created + assert skipped == [] + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + assert pm.has_param("_globals.qubit.IF") + assert pm.list_locks() == {} + # the stored Target persists: a later Instance gets the Lock at creation + pm.add_instance("qubit", "q01") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + + def test_lock_type_parameter_skips_a_lock_on_another_target_with_a_warning( pm, caplog ): @@ -2226,6 +2247,23 @@ def test_add_instance_locks_a_kept_parameter_and_keeps_its_own_value_and_unit(pm assert pm.instances_of("qubit") == ["q01", "q02", "q05"] +def test_add_instance_relocks_a_follower_that_fell_out_and_came_back(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q01.IF") # unlocked individually, remembers the Target + pm.remove_parameter("q01.octave_gain") # q01 stops matching + assert pm.instances_of("qubit") == ["q02"] + + pm.add_instance("qubit", "q01") + + # q01 is a new Instance again: its unlocked Lock remembering the same + # Target is locked again + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q01.IF") == pm.get("_globals.qubit.IF") + + def test_add_instance_skips_a_kept_parameter_locked_to_another_target(pm, caplog): put_qubit_instances(pm) pm.lock_type_parameter("qubit", "IF") @@ -2287,6 +2325,109 @@ def test_add_instance_skips_the_type_lock_when_the_target_is_gone(pm, caplog): assert "does not exist" in message +def test_add_instance_skips_an_application_the_batch_made_a_cycle(pm, caplog): + # the Targets follow the q05 parameters crosswise: locking q05.a to z1 + # succeeds and only then makes locking q05.b to z2 a cycle — the + # up-front classification ran against the pre-batch state, so the + # application loop must skip instead of raising + pm.add_type("T") + pm.add_type_parameter("T", "a", default=1, unit="V") + pm.add_type_parameter("T", "b", default=2, unit="A") + pm.add_type_parameter("T", "c", default=3, unit="W") + pm.add_parameter("q05.a", initial_value=1, unit="V") + pm.add_parameter("q05.b", initial_value=2, unit="A") + pm.add_parameter("z1", initial_value=0, unit="V") + pm.add_parameter("z2", initial_value=0, unit="A") + pm.lock("z1", "q05.b") + pm.lock("z2", "q05.a") + # no Instances yet: the declarations only store their Targets + assert pm.lock_type_parameter("T", "a", target="z1") == [] + assert pm.lock_type_parameter("T", "b", target="z2") == [] + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("T", "q05") + + # the creation itself succeeded and q05 is an Instance + assert pm.has_param("q05.c") + assert pm.instances_of("T") == ["q05"] + # the first application ran and locked q05.a to z1 + assert pm.get_lock("q05.a") == PMLockBluePrint( + target="parameter_manager.z1", locked=True + ) + # the second application became a cycle only through the first one: + # skipped instead of raised, with one warning naming it and the reason + assert pm.get_lock("q05.b") is None + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q05.b'" in message + assert "cycle in Lock targets" in message + + +def test_add_instance_skips_a_parameter_that_cannot_carry_a_lock(pm, caplog): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # q09 carries a plain qcodes Parameter at the entry path — created + # directly on the Parameter Group, bypassing the root — and lacks + # octave_gain: not an Instance yet + parent = pm._get_parent("q09.IF", create_parent=True) + parent._add_own_parameter("IF", set_cmd=None, unit="Hz") + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q09") + + # the creation itself succeeded and q09 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q09"] + assert pm.get("q09.octave_gain") == 10 + # the plain parameter cannot carry a Lock: skipped, with one warning + assert pm.get_lock("q09.IF") is None + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q09.IF'" in message + assert "cannot carry a Lock" in message + + +def test_add_instance_skips_an_application_that_would_close_a_cycle(pm, caplog): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # q10 carries IF with its own value and lacks octave_gain; the Globals + # Target follows q10.IF — a Globals parameter is an ordinary parameter + # and may itself carry a Lock (D18) + pm.add_parameter("q10.IF", initial_value=4e9, unit="Hz") + pm.lock("_globals.qubit.IF", "q10.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q10") + + # the creation itself succeeded and q10 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q10"] + # locking q10.IF to the Globals Target would close the cycle + # _globals.qubit.IF -> q10.IF -> _globals.qubit.IF: skipped + assert pm.get_lock("q10.IF") is None + assert pm.get("q10.IF") == 4e9 + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q10.IF'" in message + assert "cycle in Lock targets" in message + + def test_the_creation_methods_leave_an_existing_instance_lock_state_untouched(pm): put_qubit_instances(pm) pm.lock_type_parameter("qubit", "IF") @@ -2375,6 +2516,37 @@ def test_add_nested_type_locks_a_pre_existing_parameter_when_it_completes(pm): assert pm.get_lock("q01.readout.window") is None +def test_add_nested_type_locks_a_pre_existing_parameter_through_two_levels(pm): + # the union walk reaches the inner position through two nesting levels: + # super nests qubit at q, qubit nests readout at readout + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("s01.top", initial_value=1, unit="") + pm.add_parameter("s01.q.octave_gain", initial_value=10, unit="dB") + # the inner position carries readout.IF only: not a readout-Instance + # yet, so the declaration locks nobody + pm.add_parameter("s01.q.readout.IF", initial_value=10e6, unit="Hz") + assert pm.instances_of("readout") == [] + assert pm.lock_type_parameter("readout", "IF") == [] + + pm.add_nested_type("qubit", "readout", "readout") + + # the edit created the missing window, completing s01.q.readout into a + # new Instance of readout: its PRE-EXISTING IF gets the Type Lock + assert pm.instances_of("readout") == ["s01.q.readout"] + assert pm.get_lock("s01.q.readout.IF") == PMLockBluePrint( + target="parameter_manager._globals.readout.IF", locked=True + ) + # the entry without a Type Lock stays unlocked + assert pm.get_lock("s01.q.readout.window") is None + + def test_unlock_type_parameter_leaves_every_lock_in_place(pm): put_qubit_instances(pm) pm.lock_type_parameter("qubit", "IF") From ba479cea4a4f7bee935f125d51416766eae4374d Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 09:12:24 -0500 Subject: [PATCH 056/107] 3.2: history --- HISTORY_parameter_manager_redesign.md | 49 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 47ad7b0..1c3b422 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -485,3 +485,52 @@ The Type API in `src/instrumentserver/params.py` now emits its own Broadcasts (D ### Process notes - Two permissions were rejected. In round 0, reviewer-qwen tried an inline `python -c` script that changed into a `tempfile.mkdtemp()` outside the repo. It reran its check as a scanned script under `orchestration/3.1/round-0/`. In round 1, plan-checker-qwen tried a command that wrote to `/tmp/x`. Reviewers' scratch scripts and logs under `orchestration/3.1/` were scanned before they ran and deleted afterwards. There were no stalls and no nudges. + +## 3.2 `lock_type_parameter` / `unlock_type_parameter` — 2026-09-25 + +`ParameterManager.lock_type_parameter(type_name, path, target=None)` declares a Type Lock on one of the Type's own entries (D17). It stores the Target on `_TypeEntry.target` in full form (`parameter_manager._globals.qubit.IF`), which `PMTypeBluePrint.parameters[path]["target"]` now shows. With no `target` it creates the default Globals Target through 3.1's `_ensure_global_target`. It then puts an ordinary locked Lock on that parameter in every current Instance through `lock()`/`relock()`. Instance parameters with a Lock on another Target are skipped, named in one `logger.warning` and returned as a list of relative paths. `unlock_type_parameter` clears the stored Target only, and every Lock stays. The new `_apply_type_locks_to_new_instances` runs at the end of `add_instance`, `add_type_parameter` and `add_nested_type`. It gives every submodule that became an Instance during the edit the Type Locks of its Type, pre-existing parameters included. Broadcasts go out in this order: `parameter-creation`s, then `pm-lock-update`s, then `pm-type-update`. `test/pytest/test_pm_types.py` grew from 124 to 161 tests. + +### Commit by commit +- `682ab21` The two methods, `_classify_lock_application` (lock / relock / none / skip for one parameter), the creation-path application and 31 tests. The orchestrator's nine readings in the coder spec set these rules: + - full-form stored Target + - own entries only, via `_require_type_entry` + - validate first, with an up-front self-lock and cycle check across all Instances that names every offender + - a re-declare with a different Target skips the Followers still locked to the old one + - `unlock_type_parameter` on an entry with no Type Lock is an INFO no-op that emits nothing + - only the edited Type gets a `pm-type-update` + + Two coder questions changed reading 6. It had said only *created* parameters get Type Locks. The orchestrator answered from D17 ("new Instances ... get it at creation") that the unit is the Instance. A submodule that becomes an Instance (instances after the edit minus instances before) gets the Type Locks on all its parameters at those paths, kept ones included. Second, `add_type_parameter` can never complete a new Instance under the 2.3 loops, because it only writes into existing Instances. So its test became a negative one (`test_add_type_parameter_applies_no_type_locks`). `add_nested_type` can complete one, so it walks the union of `_nesting_prefixes` of the outer Type and the Nested Type (`test_add_nested_type_applies_the_nested_type_lock_under_the_submodule`). The coder made one judgment call. On the creation path, a Lock that would close a cycle or hit a parameter that cannot carry a Lock is skipped with the warning, so the three creation methods never raise. `lock_type_parameter` raises up front instead, and also checks "cannot carry a Lock" there. The tests cover the plan's list and more: + - apply, the default Globals Target, skip-with-warning (`caplog`), no skips means no warning + - a new Instance auto-locked, a kept parameter locked with its value and unit unchanged, a missing stored Target skipped + - rule removal leaves the Locks (`test_unlock_type_parameter_leaves_every_lock_in_place`), `test_an_instance_falling_out_keeps_its_locks` + - re-declare re-applies, re-declare with a different Target, an explicit ordinary Target + - refusals: unknown Type, non-own entry, missing Target, self-lock, cycle, and `test_lock_type_parameter_names_every_offending_path` + - five sink tests on Broadcast order and silence + - two proxy tests: the skipped list and the full-form `target` over the wire, and a `SubClient` receiving the Type Lock Broadcasts from a second client + + Orchestrator run: ruff clean, 155 in `test_pm_types.py`, 393 in the full suite. +- `efbafc6` Fix from round 0, four items: + - `_apply_type_locks_to_new_instances` classified every application against the state before the batch, but each `lock()` checks again against the Locks that earlier applications in the same batch had already made. reviewer-qwen (must-fix) built a case: `z1` locked to `q05.b`, `z2` locked to `q05.a`, and Type Locks on `a`→`z1` and `b`→`z2`. There `add_instance("T","q05")` created `q05.c`, locked `q05.a`, then raised a cycle error, leaving the Instance half-locked. The orchestrator reproduced it. The loop now catches `ValueError` from `lock()`/`relock()` and records the exception text as that parameter's skip reason (`test_add_instance_skips_an_application_the_batch_made_a_cycle`). + - Tests for the coder's creation-path skips: `test_add_instance_skips_a_parameter_that_cannot_carry_a_lock` and `test_add_instance_skips_an_application_that_would_close_a_cycle` (both test reviewers). + - The `relock` branch at creation: `test_add_instance_relocks_a_follower_that_fell_out_and_came_back` (both test reviewers). The outer-side `add_nested_type` variant the fix list asked for cannot be built, because a submodule that carries the outer Type's full pre-nesting set is already an Instance. The orchestrator accepted the coder's three-tier substitute, `test_add_nested_type_locks_a_pre_existing_parameter_through_two_levels`. + - `test_lock_type_parameter_with_no_instances_stores_the_rule`: declare first, add the Instance later (test-reviewer-qwen, should-fix). + + All six approved in re-review, and every raiser confirmed their item fixed. Orchestrator run: ruff clean, 161 in `test_pm_types.py`, 399 in the full suite. + +### Dropped findings +- The `seen` set in `_apply_type_locks_to_new_instances` de-duplicates by parameter path alone. Two different defining entries with different Targets that reach one path in the same edit would drop the second without a warning (reviewer-glm, reviewer-qwen, nits) → not sent. reviewer-glm found no way to reach it through the public API. The plan does not say which Target should win. Flagged for Marcos. +- "Only the rule goes" wording in the docstring and section comment, and "stores the rule" in the round-1 test name (reviewer-glm, plan-checker-glm, then five reviewers in round 1, nits) → not sent, because it mirrors D17. Reading 8 of the coder spec says "rule" alone is not a glossary term, and the wording is still in `params.py` and `test_pm_types.py`. +- Self-lock and cycle messages are pinned with one offender only. Broadcast order is not pinned for `add_nested_type` or for a mixed re-declare (test reviewers, nits) → not sent. + +### Questions to Marcos +- Both coder questions (Type Locks apply to the whole new Instance, kept parameters included; D17's "an `add_type_parameter` that completes them" has no reachable case) were answered by the orchestrator from D17 and flagged for Marcos, along with the nine readings and the `seen` de-dup. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- For 3.3: a stored Target that no longer exists is skipped with a warning on the creation path, until deletion clears Type Locks. +- plan-checker-qwen: suppose a Nested Type sits at a dotted, deeper position (`add_nested_type("M", "s.s2", "N")`). A new Instance of `M` that this edit completes would not get `M`'s own-entry Type Locks. This is recorded in `decisions.md` only, not fixed and not tested. +- Nothing from 3.2 is in `TEST_AUDIT.md`. + +### Process notes +- Marcos told the run during 3.2 to continue through every phase instead of stopping at the end of Phase 3 (`orchestration/RUNS.md`). +- The coder went idle after the orchestrator answered its first question, with no edits and no worker_done. One nudge got it moving again. +- Four permissions were rejected. reviewer-qwen and test-reviewer-qwen each asked for opencode's temp directory outside the repo. Each also first ran a scratch script (`repro_cross_target.py`, `scratch-verify.py`) that changed into a temp directory outside the repo. Both scripts were rewritten to run from their round folder, and were scanned and allowed. All scratch files and logs under `orchestration/3.2/` were deleted afterwards. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index fb7d4b7..d0888fc 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -510,7 +510,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session raise; `_globals` excluded from `instances_of`/`types_of` (already in 2.2, assert again); internal helper `_ensure_global_target(type, path)` creating `_globals..` with the entry's default and unit. Tests: `test_pm_types.py`. -- [ ] **3.2 `lock_type_parameter` / `unlock_type_parameter`.** Per D17: store `target` on the +- [x] **3.2 `lock_type_parameter` / `unlock_type_parameter`.** Per D17: store `target` on the entry; default Target via 3.1; put a locked Lock on every current Instance's parameter, skipping those with a Lock on another Target (collect, `logger.warning`, return the list); `unlock_type_parameter` clears the rule only. `add_instance` and completing From 6b033a3d3e0b04c10fba978742e08d42856c3bcf Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 09:19:35 -0500 Subject: [PATCH 057/107] 3.3: remove_parameter clears the Type Locks whose Target it deletes --- src/instrumentserver/params.py | 60 +++- test/pytest/test_pm_types.py | 499 +++++++++++++++++++++++++++++++-- 2 files changed, 533 insertions(+), 26 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index b8f3676..4bbf5e9 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -428,7 +428,10 @@ class ParameterManager(Broadcaster, ParameterGroup): :meth:`unlock_type_parameter` do both: they emit the ``pm-lock-update`` Broadcasts of the Locks the declaration creates and the ``pm-type-update`` of the edited Type, and every new Instance gets - the existing Type Locks of its Type at creation (D17). + the existing Type Locks of its Type at creation (D17). Removing a + parameter that is the stored Target of Type Locks drops the Locks + pointing at it and clears those Type Locks, emitting one + ``pm-type-update`` per affected Type (D18). For the parameter manager to recognize other profiles in disk, the profile filename needs to start with 'parameter_manager-' @@ -515,12 +518,27 @@ def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: with their own values again. One ``pm-lock-update`` Broadcast with a ``None`` value is emitted per dropped Lock (D10). + When the removed parameter is the stored Target of one or more Type + Locks — a Globals parameter, or any parameter an explicit Type Lock + points at — the Type Lock is cleared as well (D18): every own entry + of every Type whose stored Target it was gets ``target=None``, and + one ``pm-type-update`` Broadcast per affected Type, in registry + order and carrying the Type's fresh :class:`PMTypeBluePrint`, is + emitted after the ``pm-lock-update``s (D22). The Locks a cleared + Type Lock had put on Instance parameters are ordinary Locks + pointing at the removed parameter, so the same cleanup drops them; + a parameter that is no Type Lock Target emits no + ``pm-type-update``. + Same signature and deletion behaviour as :meth:`ParameterGroup.remove_parameter`; the path is relative to - this Parameter Manager. + this Parameter Manager. The deletion itself emits nothing here: + the Server announces a direct ``remove_parameter`` call with a + ``parameter-deletion`` Broadcast (ADR-0003). """ - # validate-then-mutate: the parameter must exist before any Lock is - # touched. The checks mirror what the deletion itself would raise. + # validate-then-mutate: the parameter must exist before any Lock or + # Type Lock is touched. The checks mirror what the deletion itself + # would raise. parent = self._get_parent(param_name) pname = param_name.split(".")[-1] if pname not in parent.parameters: @@ -538,8 +556,31 @@ def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: param.lock = None param._target = None dropped_followers.append(rel_path) + + # every Type Lock whose stored Target the removed parameter was is + # cleared with it (D18): the entry keeps no Target that points + # nowhere, so no later Instance is locked to a missing parameter. + # Own entries only: a nesting Type's effective set carries units + # and defining Types, not Targets, so only the Type owning the + # entry is affected (like lock_type_parameter). + affected_types: List[str] = [] + for type_name, definition in self._types.items(): + touched = False + for entry in definition.parameters.values(): + if entry.target == target_full: + entry.target = None + touched = True + if touched: + affected_types.append(type_name) + + # broadcasts after the mutation, in order: one pm-lock-update with + # None per dropped Follower (D10), then one pm-type-update per + # affected Type, in registry order (D22); the deletion itself + # emits nothing here for rel_path in dropped_followers: self._broadcast_lock_update(rel_path, None) + for type_name in affected_types: + self._broadcast_type_update(type_name) super().remove_parameter(param_name, cleanup) @@ -1899,8 +1940,9 @@ def add_instance(self, type_name: str, name: str) -> None: # the same one the public ``add_parameter`` ends in. A Globals # parameter is otherwise ordinary (D18): it can be set and read, it # may itself carry a Lock and be a Lock Target, and it is saved with - # the profile. Removing one is allowed; the Lock and Type Lock - # cleanup it triggers is task 3.3. + # the profile. Removing one is allowed; :meth:`remove_parameter` then + # drops the Locks pointing at it and clears the Type Lock it was the + # stored Target of (D18). # ------------------------------------------------------------------ def _ensure_global_target(self, type_name: str, path: str) -> str: @@ -2175,8 +2217,10 @@ def _apply_type_locks_to_new_instances( Per parameter an unlocked Lock remembering the same Target is locked again, an already locked one is left as it is, and a Lock - on another Target is skipped. A Target that no longer exists - (possible until task 3.3 clears the stored Target on deletion), a + on another Target is skipped. A Target that no longer exists — + not reachable through the public API, since + :meth:`remove_parameter` clears the Type Lock whose stored Target + it deletes (D18), but possible in a registry inserted by hand — a parameter that cannot carry a Lock and one whose Lock application would be a self-lock or close a cycle are skipped too — including a cycle that another application of the same batch creates, since diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index 7e271f0..badc5d5 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -77,6 +77,27 @@ Target in ``get_type``, a locked Instance parameter pulling the Globals value) and a SubClient receiving the declaration Broadcasts made by a second client. +The deletion interplay part (3.3) checks that removing a parameter that is +the stored Target of Type Locks — the default Globals one or an explicit +ordinary one, carried by one Type or shared by two — drops every Lock +pointing at it (the Followers answer ``get`` with their own values again) +and clears the Type Locks (``get_type`` shows ``target`` ``None``), that +removing a Target of ordinary Locks touches no Type Lock, that removing a +Follower touches none either, that a refused removal leaves Locks and +Types untouched, that ``remove_type`` leaves the Globals parameters and +every Instance Lock alone while ``remove_type_parameter`` takes the +entry's Type Lock with it, and that ``remove_all_parameters`` clears the +Type Locks whose Targets it removes while the Type definitions stay. The +Broadcast part pins the order: one ``pm-lock-update`` with ``None`` per +dropped Follower, then one ``pm-type-update`` per affected Type in +registry order, and nothing else; ``remove_type`` keeps its single +``None`` update with no ``pm-lock-update``; a refused removal and a +Follower removal emit nothing. The proxy part removes a Globals Type Lock +Target through a second client: the SubClient sees the ``pm-lock-update`` +(``None``) per Follower, the ``pm-type-update`` with the cleared Target +and the Server's ``parameter-deletion``, the first client's proxy shows +the Follower unlocked after ``update()``, and ``get_type`` over the wire +shows ``target`` ``None``. """ import copy @@ -87,6 +108,7 @@ from instrumentserver.blueprints import ( PARAMETER_CREATION, + PARAMETER_DELETION, PM_LOCK_UPDATE, PM_TYPE_UPDATE, ParameterBroadcastBluePrint, @@ -2298,31 +2320,32 @@ def test_add_instance_skips_a_kept_parameter_locked_to_another_target(pm, caplog assert "parameter_manager.q00.IF" in message -def test_add_instance_skips_the_type_lock_when_the_target_is_gone(pm, caplog): +def test_after_a_deletion_cleared_the_type_lock_a_new_instance_gets_no_lock( + pm, +): put_qubit_instances(pm) pm.lock_type_parameter("qubit", "IF") - # the Globals Target is removed; until task 3.3 the entry keeps the - # stored Target, and the application must skip instead of raising - pm.remove_parameter("_globals.qubit.IF") - caplog.clear() - with caplog.at_level(logging.WARNING): - pm.add_instance("qubit", "q07") + # removing the Globals Target clears the Type Lock (D18, task 3.3) + pm.remove_parameter("_globals.qubit.IF") + assert pm.get_type("qubit").parameters["IF"]["target"] is None - # the creation itself still succeeds; the created parameter carries - # no Lock + # no stored Target remains, so a later Instance applies no Lock for + # that entry + pm.add_instance("qubit", "q07") assert pm.instances_of("qubit") == ["q01", "q02", "q07"] assert pm.get_lock("q07.IF") is None assert pm.get("q07.IF") == 5e9 - warnings_ = [ - r - for r in caplog.records - if r.levelno == logging.WARNING and r.name == "instrumentserver.params" - ] - assert len(warnings_) == 1 - message = warnings_[0].getMessage() - assert "'q07.IF'" in message - assert "does not exist" in message + + # declaring the Type Lock again recreates the Globals Target on demand + # (3.1) and locks every Instance parameter + pm.lock_type_parameter("qubit", "IF") + assert pm.has_param("_globals.qubit.IF") + assert pm.get("_globals.qubit.IF") == 5e9 + for inst in ("q01", "q02", "q07"): + assert pm.get_lock(f"{inst}.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) def test_add_instance_skips_an_application_the_batch_made_a_cycle(pm, caplog): @@ -2791,6 +2814,206 @@ def test_lock_type_parameter_names_every_offending_path(pm): assert not pm.has_param("_globals.qubit.IF") +# --------------------------------------------------------------------------- +# Deletion interplay (plan task 3.3, D17/D18) +# +# Removing a parameter that is the stored Target of Type Locks — a Globals +# parameter, or any parameter an explicit Type Lock points at — drops +# every Lock pointing at it and clears those Type Locks (the entries' +# Targets go back to None). remove_type takes the Type and its Type Locks +# but leaves the Globals parameters and every Instance Lock alone; +# remove_type_parameter takes the entry's Type Lock with it under the +# same reservations. After a deletion cleared a Type Lock, a later +# add_instance applies no Lock for that entry and a re-declared +# lock_type_parameter recreates the Globals Target on demand. +# --------------------------------------------------------------------------- + + +def test_removing_the_globals_target_drops_every_lock_and_clears_the_type_lock( + pm, +): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.set("_globals.qubit.IF", 7e9) + + pm.remove_parameter("_globals.qubit.IF") + + # every Follower is unlocked and answers get with its own value again + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get("q01.IF") == 5e9 + assert pm.get("q02.IF") == 6e9 + # the Type Lock is cleared + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + +def test_removing_a_shared_target_clears_the_type_locks_of_every_type(pm): + # two Types declaring their Type Lock on one explicit Target; the + # Target is a root parameter, which is never an Instance (D12), and + # the two Instances differ in unit so each matches only its own Type + pm.add_parameter("shared_IF", initial_value=9e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q02.IF", initial_value=2e9, unit="V") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type("qubit2") + pm.add_type_parameter("qubit2", "IF", default=6e9, unit="V") + pm.lock_type_parameter("qubit", "IF", target="shared_IF") + pm.lock_type_parameter("qubit2", "IF", target="shared_IF") + + pm.remove_parameter("shared_IF") + + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get_type("qubit").parameters["IF"]["target"] is None + assert pm.get_type("qubit2").parameters["IF"]["target"] is None + + +def test_removing_an_ordinary_explicit_target_does_the_same(pm): + put_qubit_instances(pm) + pm.add_parameter("shared.IF", initial_value=9e9, unit="Hz") + pm.lock_type_parameter("qubit", "IF", target="shared.IF") + + pm.remove_parameter("shared.IF") + + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get("q01.IF") == 5e9 + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + +def test_removing_a_target_of_ordinary_locks_only_touches_no_type_lock(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.add_parameter("q01Data.IF", initial_value=1e9, unit="Hz") + pm.lock("q02.octave_gain", "q01Data.IF") + + pm.remove_parameter("q01Data.IF") + + # the ordinary Lock is dropped, the Type Lock and its Followers are + # untouched + assert pm.get_lock("q02.octave_gain") is None + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + + +def test_removing_a_target_of_ordinary_and_type_locks_cleans_both_up(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # an ordinary Follower of the Globals Target on top of the Type Lock + # Followers + pm.add_parameter("q01Data.IF", initial_value=1e9, unit="Hz") + pm.lock("q01Data.IF", "_globals.qubit.IF") + + pm.remove_parameter("_globals.qubit.IF") + + assert pm.get_lock("q01Data.IF") is None + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + +def test_removing_a_follower_touches_no_type_lock(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_parameter("q01.IF") # a Follower, not the Target + + # q01 stops matching and its Lock is gone with the parameter, but the + # Type Lock and the other Follower are untouched + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.instances_of("qubit") == ["q02"] + + +def test_the_removal_refusal_leaves_locks_and_types_untouched(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + before = lock_state(pm) + + with pytest.raises(KeyError): + pm.remove_parameter("q01.nope") + with pytest.raises(ValueError): + pm.remove_parameter("nope.IF") + + assert lock_state(pm) == before + assert pm.has_param("_globals.qubit.IF") + + +def test_remove_type_leaves_the_globals_parameters_and_instance_locks(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q02.IF") # one Follower unlocked individually + + pm.remove_type("qubit") + + # the Type and with it its Type Locks are gone, but the Globals + # parameters stay, ordinary as ever (D13, D18) + assert pm.list_types() == [] + assert pm.has_param("_globals.qubit.IF") + assert pm.get("_globals.qubit.IF") == 5e9 + assert pm.parameter("_globals.qubit.IF").unit == "Hz" + # every Instance Lock stays, locked or unlocked + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=False + ) + assert pm.get("q01.IF") == 5e9 + assert pm.get("q02.IF") == 6e9 + + +def test_remove_type_parameter_takes_the_type_lock_with_it(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_type_parameter("qubit", "IF") + + # the entry and with it the Type Lock are gone (D13) + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + } + # the Locks and the Globals parameter stay + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.has_param("_globals.qubit.IF") + # the Instances keep the parameter and still carry the remaining + # entry, so they keep matching (D13, D1: extra parameters don't + # matter, and octave_gain is still required and present) + assert pm.has_param("q01.IF") + assert pm.instances_of("qubit") == ["q01", "q02"] + + +def test_remove_all_parameters_clears_the_type_locks_whose_targets_it_removes( + pm, +): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_all_parameters() + + # remove_all_parameters routes through remove_parameter, so removing + # the Globals Target clears the Type Lock as a consequence (D18); the + # Type definitions themselves stay (task 4.3 clears them when + # switching profiles) + assert pm.list() == [] + assert pm.list_types() == ["qubit"] + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + # --------------------------------------------------------------------------- # pm-type-update and side-effect creation Broadcasts (plan task 2.5, D22) # @@ -3342,6 +3565,156 @@ def test_failed_type_lock_calls_emit_nothing(pm_with_sink): assert pm.list_locks() == {} +# --------------------------------------------------------------------------- +# Deletion Broadcasts (plan task 3.3, D10/D18/D22) +# +# Removing a Type Lock Target emits one pm-lock-update with None per +# dropped Follower (tree order), then one pm-type-update per affected +# Type (registry order, Target cleared) — and nothing else; the deletion +# itself is announced by the Server only. remove_type still emits exactly +# one pm-type-update with None and no pm-lock-update. A refused removal +# emits nothing. +# --------------------------------------------------------------------------- + + +def test_removing_a_type_lock_target_emits_lock_updates_then_type_updates( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.remove_parameter("_globals.qubit.IF") + + # two pm-lock-updates with None, one per dropped Follower in tree + # order, then one pm-type-update for the affected Type — nothing else + assert len(received) == 3 + first, second, type_update = received + assert first.name == "parameter_manager.q01.IF" + assert first.action == PM_LOCK_UPDATE + assert first.value is None + assert second.name == "parameter_manager.q02.IF" + assert second.action == PM_LOCK_UPDATE + assert second.value is None + assert type_update.name == "parameter_manager.qubit" + assert type_update.action == PM_TYPE_UPDATE + assert isinstance(type_update.value, PMTypeBluePrint) + assert type_update.value.parameters["IF"]["target"] is None + + +def test_removing_a_shared_target_emits_one_update_per_type_in_registry_order( + pm_with_sink, +): + pm, received = pm_with_sink + # two Types sharing one explicit Target, one Instance each; the + # Target is a root parameter, which is never an Instance (D12), and + # the two Instances differ in unit so each matches only its own Type + pm.add_parameter("shared_IF", initial_value=9e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q02.IF", initial_value=2e9, unit="V") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type("qubit2") + pm.add_type_parameter("qubit2", "IF", default=6e9, unit="V") + pm.lock_type_parameter("qubit", "IF", target="shared_IF") + pm.lock_type_parameter("qubit2", "IF", target="shared_IF") + received.clear() + + pm.remove_parameter("shared_IF") + + # one pm-lock-update per dropped Follower, then one pm-type-update per + # affected Type in registry order (qubit was created before qubit2) + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == "parameter_manager.q01.IF" + assert received[1].name == "parameter_manager.q02.IF" + assert received[2].name == "parameter_manager.qubit" + assert received[3].name == "parameter_manager.qubit2" + for update in received[2:]: + assert isinstance(update.value, PMTypeBluePrint) + assert update.value.parameters["IF"]["target"] is None + + +def test_removing_a_target_of_ordinary_and_type_locks_emits_both_cleanups( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.add_parameter("q01Data.IF", initial_value=1e9, unit="Hz") + pm.lock("q01Data.IF", "_globals.qubit.IF") # an ordinary Follower too + received.clear() + + pm.remove_parameter("_globals.qubit.IF") + + # three dropped Followers in tree order — q01Data is a Parameter Group + # created after the Instances and the Globals submodule, so its + # Follower comes last — then one pm-type-update + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == "parameter_manager.q01.IF" + assert received[1].name == "parameter_manager.q02.IF" + assert received[2].name == "parameter_manager.q01Data.IF" + assert received[3].name == "parameter_manager.qubit" + + +def test_remove_all_parameters_emits_the_type_lock_clearing(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.remove_all_parameters() + + # remove_all_parameters deletes the Followers before the Globals + # Target, so no pm-lock-update is left to emit when the Target goes; + # the Type Lock clearing itself still emits its pm-type-update + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] + assert received[0].name == "parameter_manager.qubit" + assert received[0].value.parameters["IF"]["target"] is None + + +def test_remove_type_emits_no_lock_update_and_leaves_everything_else(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.remove_type("qubit") + + # exactly one pm-type-update with None and no pm-lock-update: the + # Globals parameters and every Instance Lock stay + assert len(received) == 1 + assert received[0].name == "parameter_manager.qubit" + assert received[0].action == PM_TYPE_UPDATE + assert received[0].value is None + assert pm.has_param("_globals.qubit.IF") + assert pm.list_locks() != {} + + +def test_a_refused_parameter_removal_emits_nothing(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + with pytest.raises(KeyError): + pm.remove_parameter("q01.nope") + with pytest.raises(ValueError): + pm.remove_parameter("nope.IF") + + assert received == [] + + # --------------------------------------------------------------------------- # Type API and Broadcasts through a client proxy against a live Server # (plan task 2.5) @@ -3663,3 +4036,93 @@ def test_subclient_receives_the_type_lock_broadcasts_from_a_second_client( finally: second_cli.disconnect() _cleanup_proxy_lock_types(params) + + +# --------------------------------------------------------------------------- +# Deletion interplay through a client proxy against a live Server +# (plan task 3.3) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3) and announces direct remove_parameter calls with a +# parameter-deletion Broadcast, so removing a Globals Type Lock Target +# over the wire reaches a SubClient as one pm-lock-update with None per +# dropped Follower, the pm-type-update with the cleared Target, and the +# parameter-deletion — in this order. The server-side Parameter Manager +# is shared by all tests of this module, so every test removes the +# parameters and Types it created again. +# --------------------------------------------------------------------------- + +PROXY_DEL_TYPE = "ptd_qubit" +PROXY_DEL_INSTANCES = ("ptd_q01", "ptd_q02") + + +def _cleanup_proxy_deletion_types(params): + """Remove every parameter (the Globals Targets included) and the Type + the deletion-interplay proxy tests create, so the module's shared + server-side Parameter Manager starts each test clean.""" + for path in list(params.list()): + top = path.split(".")[0] + if top.startswith("ptd_") or top == "_globals": + params.remove_parameter(path) + if PROXY_DEL_TYPE in params.list_types(): + params.remove_type(PROXY_DEL_TYPE) + + +def test_removing_a_globals_type_lock_target_over_the_wire( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _cleanup_proxy_deletion_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + second_params.add_type(PROXY_DEL_TYPE) + second_params.add_type_parameter(PROXY_DEL_TYPE, "IF", default=5e9, unit="Hz") + for name in PROXY_DEL_INSTANCES: + second_params.add_instance(PROXY_DEL_TYPE, name) + second_params.lock_type_parameter(PROXY_DEL_TYPE, "IF") + params.update() + + # the first client's Follower pulls the Globals Target's value + # while it exists (the Target is set through the server-side + # Parameter Group's set; the client proxy's own set is qcodes' + # local, deprecated one) + cli.call("parameter_manager.set", f"_globals.{PROXY_DEL_TYPE}.IF", 7e9) + assert getattr(params, PROXY_DEL_INSTANCES[0]).IF() == 7e9 + + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + second_params.remove_parameter(f"_globals.{PROXY_DEL_TYPE}.IF") + wait_for_broadcasts(received, n=4) + + # one pm-lock-update with None per dropped Follower, then the + # pm-type-update with the cleared Target, then the Server's + # parameter-deletion for the removed parameter + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PARAMETER_DELETION, + ] + assert received[0].name == f"parameter_manager.{PROXY_DEL_INSTANCES[0]}.IF" + assert received[0].value is None + assert received[1].name == f"parameter_manager.{PROXY_DEL_INSTANCES[1]}.IF" + assert received[1].value is None + assert received[2].name == f"parameter_manager.{PROXY_DEL_TYPE}" + assert isinstance(received[2].value, PMTypeBluePrint) + assert received[2].value.parameters["IF"]["target"] is None + assert received[3].name == ( + f"parameter_manager._globals.{PROXY_DEL_TYPE}.IF" + ) + + # the first client's proxy shows the Follower unlocked after + # update(): it answers get with its own value again and its Lock + # is gone, and get_type over the wire shows the cleared Target + params.update() + assert getattr(params, PROXY_DEL_INSTANCES[0]).IF() == 5e9 + assert params.get_lock(f"{PROXY_DEL_INSTANCES[0]}.IF") is None + assert params.get_type(PROXY_DEL_TYPE).parameters["IF"]["target"] is None + finally: + second_cli.disconnect() + _cleanup_proxy_deletion_types(params) From fc60b136b1f0aea8342f2b17a07f43996c1d7f89 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 09:31:27 -0500 Subject: [PATCH 058/107] 3.3: fix from review round 1: pin the stale-Target skip and the per-Type type-update --- TEST_AUDIT.md | 1 + test/pytest/test_pm_types.py | 72 +++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index e9859a7..6922617 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -38,6 +38,7 @@ States: | client.md | Errors and timeouts | Server validation failures arrive as generic `Exception` objects. A timeout discards the old socket, connects a replacement without retrying, lets the original Server call finish exactly once, and permits later requests. `raise_exceptions=False` logs and returns `None`; `disconnect()` is permanent | `section_handle_errors_and_timeouts` in `verify_client.py` | covered | `test_server_errors_timeout_socket_replacement_and_quiet_mode` and `test_timeout_dummy_responds_to_idn` | | user_guide/parameter_manager.md (future) | Types — `get_type` through a proxy | `bluePrintToDict` stringifies scalar leaves and `deserialize_obj` re-parses them numerically, so a Type entry `default` such as the string `"10"` comes back as the int `10` over the wire | Found during the plan 2.1 review; reproduced by round-tripping a `PMTypeBluePrint` through `bluePrintToDict`/`deserialize_obj` | gap | Pre-existing wire-format limitation shared with every blueprint payload (e.g. `PMLockBluePrint`, `ParameterBroadcastBluePrint.value`); not introduced by 2.1; noted per plan rule 6, not fixed here | | user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create every missing parameter through the public `add_parameter`, which since task 3.1 refuses names under `_globals`, so a profile file holding a `_globals.*` key raises `ValueError` on load until the Phase 4 reader (task 4.2) creates Globals parameters through the internal path | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)` | gap | Interim gap by plan sequencing (D18, D19, task 4.2), not fixed in 3.1 per plan rule 6 | +| user_guide/parameter_manager.md (future) | Locks — removing a parameter | `ParameterManager.remove_parameter` raises `KeyError()` when the parameter itself is missing but `ValueError` (from `_get_parent`) when an intermediate Parameter Group is missing, while `_get_param` raises `ValueError` for both; the 1.2/3.3 tests pin both types | Found during the plan 3.3 review (reviewer-qwen) | gap | Pre-existing since task 1.2; not changed per plan rule 6; if revisited, raise `ValueError` naming the full path and update the tests asserting `KeyError` | ## Manual checks diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index badc5d5..a075d40 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -66,7 +66,9 @@ untouched) and skips the ones it cannot lock with one warning, that ``add_nested_type`` applies the Nested Type's Type Lock under the submodule (a pre-existing parameter included) while ``add_type_parameter`` -applies none (its fresh entries carry no Target), and the refusals +applies none (its fresh entries carry no Target), that a hand-inserted +registry whose stored Target points nowhere makes the application skip +with one warning, and the refusals (unknown Type, non-own entry, missing explicit Target, self-lock, cycle, parameters that cannot carry a Lock) each leaving the tree, the Locks and the registry byte-identical. The Broadcast part checks the order @@ -90,7 +92,8 @@ Type Locks whose Targets it removes while the Type definitions stay. The Broadcast part pins the order: one ``pm-lock-update`` with ``None`` per dropped Follower, then one ``pm-type-update`` per affected Type in -registry order, and nothing else; ``remove_type`` keeps its single +registry order — one per Type even when two of its entries shared the +removed Target — and nothing else; ``remove_type`` keeps its single ``None`` update with no ``pm-lock-update``; a refused removal and a Follower removal emit nothing. The proxy part removes a Globals Type Lock Target through a second client: the SubClient sees the ``pm-lock-update`` @@ -2348,6 +2351,35 @@ def test_after_a_deletion_cleared_the_type_lock_a_new_instance_gets_no_lock( ) +def test_add_instance_skips_a_hand_inserted_stale_target(pm, caplog): + # the public API cannot leave a stored Target pointing nowhere any + # more (remove_parameter clears the Type Lock whose Target it deletes, + # D18), but a registry inserted by hand still can: the application + # must skip that entry instead of raising + put_qubit_instances(pm) + pm._types["qubit"].parameters["IF"].target = "parameter_manager.gone" + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q07") + + # the creation itself still succeeds and q07 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q07"] + assert pm.has_param("q07.IF") + assert pm.get_lock("q07.IF") is None + assert pm.get("q07.IF") == 5e9 + # one warning names the skipped path and the missing stored Target + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q07.IF'" in message + assert "does not exist" in message + + def test_add_instance_skips_an_application_the_batch_made_a_cycle(pm, caplog): # the Targets follow the q05 parameters crosswise: locking q05.a to z1 # succeeds and only then makes locking q05.b to z2 a cycle — the @@ -3640,6 +3672,42 @@ def test_removing_a_shared_target_emits_one_update_per_type_in_registry_order( assert update.value.parameters["IF"]["target"] is None +def test_two_entries_sharing_a_removed_target_emit_one_type_update(pm_with_sink): + # two entries of one Type both declaring their Type Lock on the same + # ordinary parameter: the emission is one pm-type-update per affected + # Type, not per cleared entry + pm, received = pm_with_sink + pm.add_parameter("shared_IF", initial_value=9e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.lock_type_parameter("qubit", "IF", target="shared_IF") + pm.lock_type_parameter("qubit", "octave_gain", target="shared_IF") + received.clear() + + pm.remove_parameter("shared_IF") + + # one pm-lock-update with None per dropped Follower, then exactly one + # pm-type-update whose blueprint shows both entries' Targets cleared — + # nothing else + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == "parameter_manager.q01.IF" + assert received[0].value is None + assert received[1].name == "parameter_manager.q01.octave_gain" + assert received[1].value is None + update = received[2] + assert update.name == "parameter_manager.qubit" + assert isinstance(update.value, PMTypeBluePrint) + assert update.value.parameters["IF"]["target"] is None + assert update.value.parameters["octave_gain"]["target"] is None + + def test_removing_a_target_of_ordinary_and_type_locks_emits_both_cleanups( pm_with_sink, ): From 36aa69ebf7cf71d4ad730fe6056a405e8e013f3b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 09:37:16 -0500 Subject: [PATCH 059/107] 3.3: history --- HISTORY_parameter_manager_redesign.md | 40 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 1c3b422..42a26f1 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -534,3 +534,43 @@ The Type API in `src/instrumentserver/params.py` now emits its own Broadcasts (D - Marcos told the run during 3.2 to continue through every phase instead of stopping at the end of Phase 3 (`orchestration/RUNS.md`). - The coder went idle after the orchestrator answered its first question, with no edits and no worker_done. One nudge got it moving again. - Four permissions were rejected. reviewer-qwen and test-reviewer-qwen each asked for opencode's temp directory outside the repo. Each also first ran a scratch script (`repro_cross_target.py`, `scratch-verify.py`) that changed into a temp directory outside the repo. Both scripts were rewritten to run from their round folder, and were scanned and allowed. All scratch files and logs under `orchestration/3.2/` were deleted afterwards. + +## 3.3 Deletion interplay — 2026-09-25 + +`ParameterManager.remove_parameter` now clears the Type Locks whose stored Target it deletes (D18). This covers a Globals parameter or any explicit Target. After the 1.2 Lock cleanup it sets `_TypeEntry.target` to `None` on every own entry of every Type that pointed at the removed parameter. The Broadcasts go out after the mutation, in this order: one `pm-lock-update` with `None` per dropped Follower, then one `pm-type-update` per affected Type in registry order, then the deletion. The deletion Broadcast is the Server's own `parameter-deletion`. `remove_type` and `remove_type_parameter` keep their behaviour, which is now asserted: the Type Locks go, and the Globals parameters and every Instance Lock stay. `test/pytest/test_pm_types.py` grew from 161 to 180 tests. + +### Commit by commit +- `6b033a3` The cleanup loop in `remove_parameter`, docstring updates in the class, `remove_parameter`, the Globals section comment and `_apply_type_locks_to_new_instances`, and 17 new tests. The orchestrator's readings in the coder spec set these rules: + - any Type Lock Target counts, not only Globals parameters + - own entries only + - order: Lock updates, then Type updates, then the deletion + - `remove_type` and `remove_type_parameter` stay unchanged and are only asserted + - whatever `remove_all_parameters` turns out to do is accepted and pinned + + The caller check showed that `remove_all_parameters` routes through `remove_parameter`. It deletes the Followers before the Globals Target, so it emits a single `pm-type-update` and no `pm-lock-update` (`test_remove_all_parameters_emits_the_type_lock_clearing`). The Type definitions stay for 4.3. The coder rewrote the 3.2 interim test `test_add_instance_skips_the_type_lock_when_the_target_is_gone`, whose comment said it held only until 3.3. It became `test_after_a_deletion_cleared_the_type_lock_a_new_instance_gets_no_lock`, which also checks that a re-declared `lock_type_parameter` recreates the Globals Target on demand. The defensive skip for a missing stored Target stays, documented as reachable only through a registry inserted by hand. The new tests include: + - unit: a Globals Target, two Types sharing one explicit Target, an ordinary explicit Target, a Target of ordinary Locks only, a Target of both ordinary Locks and Type Locks, a Follower removal, and the refusal (`KeyError` and `ValueError`, with `lock_state` covering `_types`) + - `test_remove_type_leaves_the_globals_parameters_and_instance_locks` and `test_remove_type_parameter_takes_the_type_lock_with_it` + - six sink tests on order and silence + - `test_removing_a_globals_type_lock_target_over_the_wire`: a `SubClient` sees two `pm-lock-update`s with `None`, the `pm-type-update` and `parameter-deletion` + + Orchestrator run: ruff clean, 178 in `test_pm_types.py`, 416 in the full suite. +- `fc60b13` Fix from round 0, three items: + - The rewrite in `6b033a3` had removed the only test of the kept stale-Target skip in `_apply_type_locks_to_new_instances`. test-reviewer-glm (should-fix) and reviewer-glm (nit) caught it. `test_add_instance_skips_a_hand_inserted_stale_target` sets `pm._types["qubit"].parameters["IF"].target` to a missing path. It asserts one warning naming `'q07.IF'` and "does not exist", and a `q07.IF` with no Lock. + - `test_two_entries_sharing_a_removed_target_emit_one_type_update` checks for one `pm-type-update` per Type, not one per cleared entry (test-reviewer-qwen, nit). The orchestrator kept it because it pins the task's "for each affected Type". + - A `TEST_AUDIT.md` row, "Locks — removing a parameter". It records that `remove_parameter` raises `KeyError` for a missing leaf but `ValueError` for a missing Parameter Group, while `_get_param` raises `ValueError` for both (reviewer-qwen, nit). This dates from 1.2 and is left alone per plan rule 6. + + All six approved in re-review with no findings, and each raiser confirmed their item fixed. Orchestrator run: ruff clean, 180 in `test_pm_types.py`, 418 in the full suite. + +### Dropped findings +- No test removes the Target of a Type Lock declared on a Nested Type's own entry (test-reviewer-glm, nit) → not sent. That case runs the same own-entry loop. The outer Type's effective set then shows `target: None` without a `pm-type-update` of its own, which is consistent with reading 1 but not pinned. test-reviewer-glm accepted the drop in round 1. + +### Questions to Marcos +- The orchestrator flagged its coder-spec readings for Marcos: any Target counts, the Broadcast order, `remove_type`/`remove_type_parameter` unchanged, and the `remove_all_parameters` consequence accepted. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- For 4.3: after `remove_all_parameters` the Type definitions stay, with their Type Locks cleared. `switch_to_profile` has to clear Types wholesale. +- `TEST_AUDIT.md`, "Locks — removing a parameter": the `KeyError`/`ValueError` mismatch. The 1.2 and 3.3 refusal tests pin both types. +- plan-checker-glm (observation): the `pm-type-update` loop calls `get_type` after the entries are cleared. A registry corrupted by hand so that `get_type` raises would make `remove_parameter` raise before the deletion. The public API can't reach that state, and every Type-editing method has the same pattern. + +### Process notes +- plan-checker-glm's round-1 worker_done was rejected by Orca because of a garbled handle. After one nudge it resent, and a later duplicate was rejected as already settled, which did no harm. There were no stalls and no rejected permissions. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index d0888fc..29942df 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -519,7 +519,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session Locks they create. Tests: `test_pm_types.py` — apply, skip-with-warning (use `caplog`), new Instance auto-locked, rule removal leaves Locks, Instance falling out keeps Locks, re-declare re-applies, explicit `target=` pointing at an ordinary parameter. -- [ ] **3.3 Deletion interplay.** `remove_parameter` on a `_globals` parameter (or any Type +- [x] **3.3 Deletion interplay.** `remove_parameter` on a `_globals` parameter (or any Type Lock Target) removes the Locks pointing at it **and** clears the Type Lock rule(s) whose Target it was, emitting `pm-type-update` for each affected Type. `remove_type` drops its rules but leaves `_globals` parameters and Instance Locks alone. Tests: `test_pm_types.py`. From 8c5db8708649739fc61e7c888f4808062ad0aad0 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 09:47:07 -0500 Subject: [PATCH 060/107] 4.1: write and shim-read the version-2 Parameter Manager profile document --- src/instrumentserver/__init__.py | 3 + src/instrumentserver/params.py | 161 +++++++- .../schemas/parameter_manager_v2.json | 88 +++++ src/instrumentserver/serialize.py | 21 +- test/pytest/test_param_manager.py | 10 +- test/pytest/test_pm_persistence.py | 344 ++++++++++++++++++ 6 files changed, 604 insertions(+), 23 deletions(-) create mode 100644 src/instrumentserver/schemas/parameter_manager_v2.json create mode 100644 test/pytest/test_pm_persistence.py diff --git a/src/instrumentserver/__init__.py b/src/instrumentserver/__init__.py index c8b9129..69a85ff 100644 --- a/src/instrumentserver/__init__.py +++ b/src/instrumentserver/__init__.py @@ -21,6 +21,9 @@ def getInstrumentserverPath(*subfolder: str) -> str: PARAMS_SCHEMA_PATH = os.path.join(getInstrumentserverPath("schemas"), "parameters.json") +PM_V2_SCHEMA_PATH = os.path.join( + getInstrumentserverPath("schemas"), "parameter_manager_v2.json" +) DEFAULT_PORT = 5555 diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 4bbf5e9..61a6001 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -507,6 +507,36 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] kw["path"] = f"{self.name}.{name}" super().add_parameter(name, **kw) + def _create_managed_parameter( + self, path: str, initial_value: Any, unit: str + ) -> None: + """Create the parameter at the dotted path ``path`` (relative to + this Parameter Manager) through the internal creation path — + ``_get_parent(..., create_parent=True)`` + + ``_add_own_parameter`` — as a :class:`ManagedParameter` whose + ``path`` is the full dotted form with the instrument name, exactly + like the public :meth:`add_parameter` creates its parameters. + Missing Parameter Groups on the way are created; a segment of + ``path`` that is an existing parameter raises ``ValueError`` + (through :meth:`_get_parent`). + + This is the shared creation path of the callers that must bypass + the public :meth:`add_parameter` refusal of the Globals name + (D18): :meth:`_ensure_global_target`, which creates the default + Target of a Type Lock declaration, and :meth:`fromParamDict`, + which creates the missing parameters a profile file asks for, + Globals parameters included. It emits nothing: the callers own + their Broadcasts. + """ + parent = self._get_parent(path, create_parent=True) + parent._add_own_parameter( + path.split(".")[-1], + parameter_class=ManagedParameter, + path=self._full_path(path), + initial_value=initial_value, + unit=unit, + ) + def _root_for_new_groups(self) -> "ParameterManager": """Parameter Groups created under the Parameter Manager belong to it: it is their root.""" @@ -1955,8 +1985,9 @@ def _ensure_global_target(self, type_name: str, path: str) -> str: default Target of a Type Lock declaration with. It bypasses the public :meth:`add_parameter` refusal of the Globals name through the internal creation path - (``_get_parent(..., create_parent=True)`` + - ``_add_own_parameter``), creating the parameter as a + (:meth:`_create_managed_parameter`, the + ``_get_parent(..., create_parent=True)`` + + ``_add_own_parameter`` pair), creating the parameter as a :class:`ManagedParameter` whose ``path`` is the full dotted form with the instrument name, like :meth:`add_parameter` does. A parameter that exists at the target path already is kept @@ -2010,14 +2041,7 @@ def _ensure_global_target(self, type_name: str, path: str) -> str: # relative target)`` pair the helper takes is reused only for its # walk, and missing Parameter Groups are created on the way self._check_creation_targets([("_globals", f"{type_name}.{path}")]) - parent = self._get_parent(global_path, create_parent=True) - parent._add_own_parameter( - global_path.split(".")[-1], - parameter_class=ManagedParameter, - path=self._full_path(global_path), - initial_value=entry.default, - unit=entry.unit, - ) + self._create_managed_parameter(global_path, entry.default, entry.unit) self._broadcast_parameter_creation(global_path, entry.default, entry.unit) return global_path @@ -2461,15 +2485,44 @@ def fromParamDict( ) -> None: """Load parameters from a parameter dictionary (see :mod:`.serialize`). - :param paramDict: Parameter dictionary. + A dictionary without a top-level ``version`` key is the legacy flat + parameter map and loads exactly as before. A version-2 profile + document (the document :meth:`toParamDict` writes, plan decision + D19) is validated as a whole with + :func:`serialize.validateParameterManagerV2`, and its + ``parameters`` map is then loaded with the same semantics as the + legacy flat map: value, unit and ``deleteMissing``. Its ``lock`` + entries and ``types`` section are ignored for now — task 4.2 loads + the Targets, the Types and the Locks in D20's order. Any other + ``version`` value raises ``ValueError`` naming it. + + A parameter the file asks for that does not exist yet is created; + one under the Globals submodule is created through the internal + creation path (:meth:`_create_managed_parameter`), since the + public :meth:`add_parameter` refuses the Globals name (D18), so a + saved Globals parameter round-trips. + + :param paramDict: Parameter dictionary — a legacy flat map or a + version-2 profile document. :param deleteMissing: If ``True``, delete parameters currently in the ParameterManager that are not listed in the file. """ - serialize.validateParamDict(paramDict) - if serialize.isSimpleFormat(paramDict): - simple = True - else: + if "version" in paramDict: + version = paramDict["version"] + if version != 2: + raise ValueError( + f"unsupported Parameter Manager profile version: " + f"{version!r} (this reader reads version 2 and the " + "legacy flat map, which carries no version key)" + ) + serialize.validateParameterManagerV2(paramDict) + paramDict = paramDict["parameters"] simple = False + else: + # legacy flat parameter map: exactly the behaviour before the + # version-2 profile document existed + serialize.validateParamDict(paramDict) + simple = serialize.isSimpleFormat(paramDict) currentParams = self.list() fileParams = [ @@ -2493,6 +2546,12 @@ def fromParamDict( assert hasattr(param, "unit") param.unit = unit + elif pn.startswith("_globals."): + # the public add_parameter refuses the Globals name (D18); + # a saved Globals parameter round-trips through the + # internal creation path + self._create_managed_parameter(pn, val, unit) + else: self.add_parameter(pn, initial_value=val, unit=unit) @@ -2503,10 +2562,76 @@ def fromParamDict( def toParamDict( self, simpleFormat: bool = False, includeMeta: List[str] = ["unit"] ) -> Dict[str, Any]: + """Return the state of this Parameter Manager as a version-2 + profile document (plan decision D19): + ``{"version": 2, "parameters": {...}, "types": {...}}``. + + ``parameters`` maps every parameter's full dotted path (with the + instrument name, like the legacy flat files) to a dict holding the + parameter's **own** value — :meth:`ManagedParameter.own_value` + for the parameters that can carry a Lock, so a locked Follower + saves its own value and never the Target's (ADR-0002), the cached + snapshot value for any plain ``Parameter`` — the metadata + ``includeMeta`` selects (``unit`` by default; Globals parameters + are saved like any other, D18), and, only on a parameter that + carries a Lock in either state, a ``lock`` entry + ``{"target": , "locked": }``. + + ``types`` maps every Type of the Type registry to its own entries + as ``{path: {"default": ..., "unit": ..., "target": ...}}`` — the + stored full-form Target of the entry's Type Lock, or ``None`` — + and its Nested Types as ``{submodule: type}``; with no Types it is + ``{}``. + + The values are read from the qcodes snapshot with ``update=False`` + (the cache, never ``get``), like the legacy writer this replaces. + + ``simpleFormat`` is kept for signature compatibility but has no + effect: a version-2 document always stores the per-parameter + dict. ``includeMeta`` still selects the per-parameter metadata + besides ``value``. + + :param simpleFormat: Ignored (kept for signature compatibility). + :param includeMeta: List of parameter attributes to include + besides value. All keys occurring in snapshots are valid. + :return: The version-2 profile document. + """ params = serialize.toParamDict( - [self], simpleFormat=simpleFormat, includeMeta=includeMeta + [self], simpleFormat=False, includeMeta=includeMeta ) - return params + for rel_path, param in self._iter_params(): + full_path = self._full_path(rel_path) + if full_path not in params: + continue + if isinstance(param, ManagedParameter): + # the snapshot of a locked Follower reports the Target's + # value (ADR-0002); the profile stores the own value, so + # unlocking exposes it again + params[full_path]["value"] = param.own_value() + lock = getattr(param, "lock", None) + if lock is not None: + params[full_path]["lock"] = { + "target": lock.target, + "locked": lock.locked, + } + return { + "version": 2, + "parameters": params, + "types": { + type_name: { + "parameters": { + path: { + "default": entry.default, + "unit": entry.unit, + "target": entry.target, + } + for path, entry in definition.parameters.items() + }, + "nested": dict(definition.nested), + } + for type_name, definition in self._types.items() + }, + } def toFile( self, @@ -2514,6 +2639,8 @@ def toFile( name: str | None = None, ) -> None: """Save parameters from the instrument into a json file. + The file holds the version-2 profile document :meth:`toParamDict` + returns (plan decision D19), dumped with ``indent=2, sort_keys=True``. If the file being saved is a profile file (starts with 'parameter_manager-' and ends with '.json'), the selectedProfile is changed to the filename. diff --git a/src/instrumentserver/schemas/parameter_manager_v2.json b/src/instrumentserver/schemas/parameter_manager_v2.json new file mode 100644 index 0000000..96d589c --- /dev/null +++ b/src/instrumentserver/schemas/parameter_manager_v2.json @@ -0,0 +1,88 @@ +{ + "type": "object", + "title": "Parameter Manager profile, version 2", + "description": "This schema validates the version-2 profile document the Parameter Manager writes and reads (plan decision D19): the parameters with their own values and the Lock of each Follower, and the Type registry.", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "version": { + "const": 2, + "description": "File format version. Saving always writes version 2; a file without this key is the legacy flat parameter map." + }, + "parameters": { + "type": "object", + "description": "Map of full dotted parameter paths (with the instrument name) to the parameter's own value, its unit and -- only on Followers -- its Lock. Validated in depth against schemas/parameters.json by serialize.validateParameterManagerV2.", + "patternProperties": { + "^(\\w+)(\\.\\w+)*$": { + "type": "object", + "description": "One parameter: its own value, the metadata included by the writer, and its Lock when it is a Follower.", + "properties": { + "value": { + "type": ["string", "object", "null", "number", "boolean", "array"], + "description": "own value of the parameter" + }, + "unit": { + "type": "string", + "description": "unit of the parameter" + }, + "lock": { + "type": "object", + "description": "The parameter's Lock (present only on Followers, in either state).", + "properties": { + "target": { + "type": "string", + "description": "full dotted path of the Target" + }, + "locked": { + "type": "boolean", + "description": "whether the Lock is currently locked" + } + }, + "required": ["target", "locked"], + "additionalProperties": false + } + }, + "required": ["value"] + } + } + }, + "types": { + "type": "object", + "description": "The Type registry: every Type with its entries and its Nested Types.", + "additionalProperties": { + "type": "object", + "description": "One Type: its own entries by relative parameter path, and its Nested Types.", + "properties": { + "parameters": { + "type": "object", + "description": "The Type's own entries by relative parameter path.", + "additionalProperties": { + "type": "object", + "description": "One entry: its default value, its unit, and the full dotted Target of its Type Lock (null when it has none).", + "properties": { + "default": { + "description": "default value of the entry" + }, + "unit": { + "type": "string", + "description": "unit of the entry" + }, + "target": { + "type": ["string", "null"], + "description": "Target of the entry's Type Lock, in the full dotted form, or null" + } + } + } + }, + "nested": { + "type": "object", + "description": "Nested Types: the submodule name that requires them mapped to the nested Type's name.", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "required": ["version", "parameters", "types"] +} diff --git a/src/instrumentserver/serialize.py b/src/instrumentserver/serialize.py index c0bb845..96b80fd 100644 --- a/src/instrumentserver/serialize.py +++ b/src/instrumentserver/serialize.py @@ -75,7 +75,7 @@ from qcodes import Parameter, Station from qcodes.instrument import InstrumentBase -from . import PARAMS_SCHEMA_PATH +from . import PARAMS_SCHEMA_PATH, PM_V2_SCHEMA_PATH logger = logging.getLogger(__name__) @@ -216,6 +216,25 @@ def validateParamDict(params: Dict[str, Any]) -> None: raise +def validateParameterManagerV2(document: Dict[str, Any]) -> None: + """Validate a version-2 Parameter Manager profile document (see + :mod:`.params`, plan decision D19). + + The whole document is validated against + ``schemas/parameter_manager_v2.json`` (the ``version`` key, the + ``lock`` entry of a Follower and the ``types`` section); the + ``parameters`` map is then validated against the per-parameter schema + ``schemas/parameters.json`` like a legacy flat parameter map. + + :param document: The profile document, with the keys ``version`` + (2), ``parameters`` and ``types``. + """ + with open(PM_V2_SCHEMA_PATH) as f: + schema = json.load(f) + validate(document, schema) + validateParamDict(document["parameters"]) + + def toDataFrame(input: SerializableType) -> "pd.DataFrame": """Make a pandas data frame from the parameters. Mainly useful for printing overviews in notebooks.""" diff --git a/test/pytest/test_param_manager.py b/test/pytest/test_param_manager.py index f8cd05b..cdd2a6f 100644 --- a/test/pytest/test_param_manager.py +++ b/test/pytest/test_param_manager.py @@ -135,7 +135,7 @@ def test_saving_correct_profile(tmp_path): with open(file_path) as file: data = json.load(file) - assert data["params.my_param"]["value"] == 8888 + assert data["parameters"]["params.my_param"]["value"] == 8888 def test_loading_correct_profile(tmp_path): @@ -150,7 +150,7 @@ def test_loading_correct_profile(tmp_path): with open(file_path) as file: data = json.load(file) - data["params.my_param"]["value"] = 9999 + data["parameters"]["params.my_param"]["value"] = 9999 with open(file_path, "w") as file: json.dump(data, file) @@ -215,9 +215,9 @@ def test_switching_profiles_automatic_save(tmp_path): with open(tmp_path.joinpath("parameter_manager-second.json")) as file: second = json.load(file) - assert second["params.his_param"]["value"] == 111 - assert second["params.nested_param.son1"]["value"] == 222 - assert second["params.nested_param.son2"]["value"] == 333 + assert second["parameters"]["params.his_param"]["value"] == 111 + assert second["parameters"]["params.nested_param.son1"]["value"] == 222 + assert second["parameters"]["params.nested_param.son2"]["value"] == 333 def test_selectedProfile_only_changing_when_correct_name(tmp_path): diff --git a/test/pytest/test_pm_persistence.py b/test/pytest/test_pm_persistence.py new file mode 100644 index 0000000..5d17a83 --- /dev/null +++ b/test/pytest/test_pm_persistence.py @@ -0,0 +1,344 @@ +"""Tests for the Parameter Manager's version-2 persistence (plan task 4.1). + +The writer part checks the document :meth:`ParameterManager.toParamDict` +produces (plan decision D19): ``version`` 2, ``parameters`` keyed by full +dotted paths with the own values, the per-Follower ``lock`` entries and the +``types`` section, the file dump format, and validity against +``schemas/parameter_manager_v2.json``. The reader part checks the +:meth:`ParameterManager.fromParamDict` shim: the legacy flat map still +loads, a version-2 document loads its parameters back (Globals included, +``deleteMissing`` semantics unchanged) while its ``lock`` entries and +``types`` section are ignored for now, and unsupported versions or invalid +documents are refused. All tests are unit tests on a local Parameter +Manager with no Server involved. +""" + +import json +import re + +import pytest +from jsonschema import ValidationError, validate + +from instrumentserver import PM_V2_SCHEMA_PATH +from instrumentserver.params import ParameterManager + + +def make_populated_manager(name="params"): + """A local Parameter Manager with a nested parameter and Types with + entries and a Nested Type, covering the writer's ``types`` section.""" + pm = ParameterManager(name=name) + pm.add_parameter(name="my_param", initial_value=123, unit="M") + pm.add_parameter(name="nested_param.child", initial_value=456, unit="a") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + return pm + + +# --------------------------------------------------------------------------- +# Writer: the version-2 document (D19) +# --------------------------------------------------------------------------- + + +def test_document_has_version_two_and_full_path_keys(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + doc = pm.toParamDict() + + assert doc["version"] == 2 + assert set(doc["parameters"]) == { + "params.my_param", + "params.nested_param.child", + } + assert doc["parameters"]["params.my_param"] == {"value": 123, "unit": "M"} + assert doc["parameters"]["params.nested_param.child"] == { + "value": 456, + "unit": "a", + } + + +def test_simple_format_argument_is_ignored_by_the_writer(tmp_path): + """A version-2 document always stores the per-parameter dict, whatever + ``simpleFormat`` says.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + assert pm.toParamDict(simpleFormat=True) == pm.toParamDict() + + +def test_locked_follower_saves_its_own_value_and_the_lock(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + + # pull on get: the live Follower answers with the Target's value + assert pm.get("q01.IF") == 42e6 + + doc = pm.toParamDict() + entry = doc["parameters"]["params.q01.IF"] + assert entry["value"] == 101735237.0 + assert entry["lock"] == {"target": "params.q01Data.IF", "locked": True} + # the Target itself carries no Lock and so no lock entry + assert "lock" not in doc["parameters"]["params.q01Data.IF"] + + +def test_unlocked_follower_stores_locked_false(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.unlock("q01.IF") + + entry = pm.toParamDict()["parameters"]["params.q01.IF"] + assert entry["value"] == 101735237.0 + assert entry["lock"] == {"target": "params.q01Data.IF", "locked": False} + + +def test_parameter_without_a_lock_has_no_lock_key(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + for path, entry in pm.toParamDict()["parameters"].items(): + assert "lock" not in entry, path + + +def test_globals_parameter_is_saved(tmp_path): + """A Globals parameter is saved like any other (D18), with its own + value and unit.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + # declaring the Type Lock creates the default Globals Target on demand + pm.lock_type_parameter("qubit", "octave_gain") + pm.set("_globals.qubit.octave_gain", 33) + + entry = pm.toParamDict()["parameters"]["params._globals.qubit.octave_gain"] + assert entry == {"value": 33, "unit": "dB"} + + +def test_types_section_holds_every_type_with_entries_and_nested(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + types = pm.toParamDict()["types"] + assert types == { + "qubit": { + "parameters": { + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + }, + "nested": {"readout": "readout"}, + }, + "readout": { + "parameters": { + "power": {"default": -10, "unit": "dBm", "target": None}, + }, + "nested": {}, + }, + } + + +def test_type_lock_target_is_stored_in_full_form(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.lock_type_parameter("qubit", "octave_gain") + + doc = pm.toParamDict() + entry = doc["types"]["qubit"]["parameters"]["octave_gain"] + assert entry["target"] == "params._globals.qubit.octave_gain" + # declaring the Type Lock created the default Globals Target on demand; + # with no Instance, no parameter entry carries a lock + assert doc["parameters"]["params._globals.qubit.octave_gain"]["value"] == 10 + for parameter_entry in doc["parameters"].values(): + assert "lock" not in parameter_entry + + +def test_no_types_give_an_empty_types_section(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="my_param", initial_value=123, unit="M") + + assert pm.toParamDict()["types"] == {} + + +def test_written_file_is_valid_against_the_v2_schema(tmp_path): + """The file a toFile call writes is valid against + schemas/parameter_manager_v2.json — with Followers in both Lock + states, a Globals parameter and a full types section in it.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.lock_type_parameter("qubit", "octave_gain") + pm.unlock("q01.IF") + + file_path = tmp_path / "parameter_manager-params.json" + pm.toFile(str(file_path)) + with open(file_path) as f: + written = json.load(f) + + with open(PM_V2_SCHEMA_PATH) as f: + schema = json.load(f) + validate(written, schema) + + +def test_file_is_dumped_with_indent_two_and_sorted_keys(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + + doc = pm.toParamDict() + file_path = tmp_path / "parameter_manager-params.json" + pm.toFile(str(file_path)) + + assert file_path.read_text() == json.dumps(doc, indent=2, sort_keys=True) + + +# --------------------------------------------------------------------------- +# Reader shim: legacy flat map, version-2 parameters, refusals +# --------------------------------------------------------------------------- + + +def test_legacy_flat_file_still_loads(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="old", initial_value=0, unit="u") + + legacy_path = tmp_path / "legacy.json" + legacy_path.write_text(json.dumps({"params.legacy": {"value": 5, "unit": "V"}})) + pm.fromFile(str(legacy_path)) + + assert pm.legacy() == 5 + assert pm.legacy.unit == "V" + # deleteMissing semantics of the legacy reader are unchanged + assert not pm.has_param("old") + + +def test_legacy_simple_format_file_still_loads(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + + legacy_path = tmp_path / "legacy_simple.json" + legacy_path.write_text(json.dumps({"params.sp": 7})) + pm.fromFile(str(legacy_path)) + + assert pm.sp() == 7 + + +def test_version_two_file_round_trips_through_from_file(tmp_path, monkeypatch): + """A version-2 file written by toFile loads its parameters back — + values and units — through fromFile.""" + monkeypatch.chdir(tmp_path) + pm = make_populated_manager() + pm.toFile(name="params") + + pm2 = ParameterManager(name="params") + + assert pm2.my_param() == 123 + assert pm2.my_param.unit == "M" + assert pm2.nested_param.child() == 456 + assert pm2.nested_param.child.unit == "a" + + +def test_globals_parameter_round_trips_through_the_internal_path(tmp_path): + """A saved Globals parameter loads back: the reader creates it through + the internal creation path, since the public add_parameter refuses the + Globals name (D18; TEST_AUDIT.md, "Profiles — loading Globals + parameters").""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.lock_type_parameter("qubit", "octave_gain") + pm.set("_globals.qubit.octave_gain", 33) + pm.toFile(tmp_path, "globals") + + pm2 = ParameterManager(name="params") + pm2.fromFile(str(tmp_path / "parameter_manager-globals.json")) + + assert pm2.has_param("_globals.qubit.octave_gain") + assert pm2.get("_globals.qubit.octave_gain") == 33 + assert pm2.parameter("_globals.qubit.octave_gain").unit == "dB" + + +def test_delete_missing_semantics_are_unchanged_on_a_version_two_load(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="a", initial_value=1, unit="u") + pm.add_parameter(name="b", initial_value=2, unit="v") + pm.toFile(tmp_path, "dm") + + pm.add_parameter(name="c", initial_value=3, unit="w") + pm.fromFile(str(tmp_path / "parameter_manager-dm.json")) + assert pm.a() == 1 + assert pm.b() == 2 + assert not pm.has_param("c") + + pm.add_parameter(name="c", initial_value=3, unit="w") + with open(tmp_path / "parameter_manager-dm.json") as f: + doc = json.load(f) + pm.fromParamDict(doc, deleteMissing=False) + assert pm.has_param("c") + assert pm.c() == 3 + + +def test_the_shim_ignores_the_lock_and_types_sections(tmp_path): + """The reader shim loads only the parameters map: the file may carry + ``lock`` entries and a ``types`` section, the parameters load with + their own values and units, and nothing about the Locks or Types is + pinned here (task 4.2 restores them).""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.lock_type_parameter("qubit", "octave_gain") + pm.unlock("q01.IF") + pm.toFile(tmp_path, "shim") + + pm2 = ParameterManager(name="params") + pm2.fromFile(str(tmp_path / "parameter_manager-shim.json")) + + assert pm2.get("q01.IF") == 101735237.0 + assert pm2.parameter("q01.IF").unit == "Hz" + assert pm2.get("q01Data.IF") == 42e6 + assert pm2.has_param("_globals.qubit.octave_gain") + + +def test_unsupported_version_raises_value_error_naming_it(): + pm = ParameterManager(name="params") + + with pytest.raises(ValueError, match=re.escape("3")): + pm.fromParamDict({"version": 3, "parameters": {}, "types": {}}) + with pytest.raises(ValueError, match=re.escape("'2'")): + pm.fromParamDict({"version": "2", "parameters": {}, "types": {}}) + # nothing was loaded into the Parameter Manager + assert pm.list() == [] + + +def test_invalid_document_is_refused_by_the_schema(): + pm = ParameterManager(name="params") + pm.add_parameter(name="a", initial_value=1, unit="u") + pm.add_parameter(name="b", initial_value=2, unit="v") + good = pm.toParamDict() + + lock_missing_locked = json.loads(json.dumps(good)) + lock_missing_locked["parameters"]["params.a"]["lock"] = {"target": "params.b"} + with pytest.raises(ValidationError): + pm.fromParamDict(lock_missing_locked) + + entry_without_value = json.loads(json.dumps(good)) + del entry_without_value["parameters"]["params.b"]["value"] + with pytest.raises(ValidationError): + pm.fromParamDict(entry_without_value) + + # the refused documents changed nothing + assert pm.a() == 1 + assert pm.b() == 2 From d36053ce2458adef3ba7ecd417fd11af526a65b5 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 10:01:17 -0500 Subject: [PATCH 061/107] 4.1: fix from review round 1: pin includeMeta, the parameters.json leg, legacy Globals loading, and the strict types schema --- TEST_AUDIT.md | 4 +- .../schemas/parameter_manager_v2.json | 8 ++- test/pytest/test_pm_persistence.py | 70 ++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 6922617..b979151 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -37,8 +37,10 @@ States: | client.md | Parameter snapshots | Relative Client-side paths, selected and all-instrument save/restore, flat and nested shapes, and nested `setParameters` rejection match the guide. Native JSON booleans still become `0.0`/`1.0` and fail QCoDeS Boolean validation | `section_save_and_restore_parameter_values` in `verify_client.py` | covered | `test_parameter_snapshot_files_and_current_boolean_limitation` covers the end-to-end workflow and explicitly references open product bug #152. The issue remains open and is not fixed in this documentation pass | | client.md | Errors and timeouts | Server validation failures arrive as generic `Exception` objects. A timeout discards the old socket, connects a replacement without retrying, lets the original Server call finish exactly once, and permits later requests. `raise_exceptions=False` logs and returns `None`; `disconnect()` is permanent | `section_handle_errors_and_timeouts` in `verify_client.py` | covered | `test_server_errors_timeout_socket_replacement_and_quiet_mode` and `test_timeout_dummy_responds_to_idn` | | user_guide/parameter_manager.md (future) | Types — `get_type` through a proxy | `bluePrintToDict` stringifies scalar leaves and `deserialize_obj` re-parses them numerically, so a Type entry `default` such as the string `"10"` comes back as the int `10` over the wire | Found during the plan 2.1 review; reproduced by round-tripping a `PMTypeBluePrint` through `bluePrintToDict`/`deserialize_obj` | gap | Pre-existing wire-format limitation shared with every blueprint payload (e.g. `PMLockBluePrint`, `ParameterBroadcastBluePrint.value`); not introduced by 2.1; noted per plan rule 6, not fixed here | -| user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create every missing parameter through the public `add_parameter`, which since task 3.1 refuses names under `_globals`, so a profile file holding a `_globals.*` key raises `ValueError` on load until the Phase 4 reader (task 4.2) creates Globals parameters through the internal path | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)` | gap | Interim gap by plan sequencing (D18, D19, task 4.2), not fixed in 3.1 per plan rule 6 | +| user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create every missing parameter through the internal creation path (`_create_managed_parameter`, which the public `add_parameter` refusal of the `_globals` name bypasses), so a profile file holding a `_globals.*` key creates the Globals parameter on load in both the legacy flat map and the version-2 document (create-on-load is intended for both, D18) | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)`; covered in 4.1 | covered | `test_legacy_flat_file_creates_a_globals_parameter_on_load` and `test_globals_parameter_round_trips_through_the_internal_path` in `test/pytest/test_pm_persistence.py` | | user_guide/parameter_manager.md (future) | Locks — removing a parameter | `ParameterManager.remove_parameter` raises `KeyError()` when the parameter itself is missing but `ValueError` (from `_get_parent`) when an intermediate Parameter Group is missing, while `_get_param` raises `ValueError` for both; the 1.2/3.3 tests pin both types | Found during the plan 3.3 review (reviewer-qwen) | gap | Pre-existing since task 1.2; not changed per plan rule 6; if revisited, raise `ValueError` naming the full path and update the tests asserting `KeyError` | +| user_guide/parameter_manager.md (future) | Profiles — loading a file | `ParameterManager.fromFile` accepts `deleteMissing` but never forwards it to `fromParamDict`, so the GUI's `fromFile(filePath=..., deleteMissing=False)` runs with the default `True` | Found during the plan 4.1 review (reviewer-glm, the coder) | gap | Pre-existing; not changed per plan rule 6; fix is a one-line forward plus a test | +| user_guide/parameter_manager.md (future) | Profiles — file validation | Both `schemas/parameters.json` and `schemas/parameter_manager_v2.json` use `patternProperties` without `additionalProperties: false`, so a parameter key that does not match `^(\w+)(\.\w+)*$` (e.g. with a space) passes validation and fails later in the loader | Found during the plan 4.1 review (plan-checker-qwen) | gap | Pre-existing in the legacy schema the plan protects; not changed per plan rule 6 | ## Manual checks diff --git a/src/instrumentserver/schemas/parameter_manager_v2.json b/src/instrumentserver/schemas/parameter_manager_v2.json index 96d589c..f08a14f 100644 --- a/src/instrumentserver/schemas/parameter_manager_v2.json +++ b/src/instrumentserver/schemas/parameter_manager_v2.json @@ -70,7 +70,9 @@ "type": ["string", "null"], "description": "Target of the entry's Type Lock, in the full dotted form, or null" } - } + }, + "required": ["default", "unit", "target"], + "additionalProperties": false } }, "nested": { @@ -80,7 +82,9 @@ "type": "string" } } - } + }, + "required": ["parameters", "nested"], + "additionalProperties": false } } }, diff --git a/test/pytest/test_pm_persistence.py b/test/pytest/test_pm_persistence.py index 5d17a83..a51015a 100644 --- a/test/pytest/test_pm_persistence.py +++ b/test/pytest/test_pm_persistence.py @@ -20,7 +20,7 @@ from jsonschema import ValidationError, validate from instrumentserver import PM_V2_SCHEMA_PATH -from instrumentserver.params import ParameterManager +from instrumentserver.params import ManagedParameter, ParameterManager def make_populated_manager(name="params"): @@ -70,6 +70,30 @@ def test_simple_format_argument_is_ignored_by_the_writer(tmp_path): assert pm.toParamDict(simpleFormat=True) == pm.toParamDict() +def test_include_meta_selects_the_per_parameter_metadata(tmp_path): + """``includeMeta`` still selects the per-parameter metadata besides + ``value``; a Follower carries its ``lock`` entry whatever + ``includeMeta`` selects.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + + doc = pm.toParamDict(includeMeta=[]) + assert doc["parameters"]["params.my_param"] == {"value": 123} + assert doc["parameters"]["params.q01.IF"]["lock"] == { + "target": "params.q01Data.IF", + "locked": True, + } + + doc = pm.toParamDict(includeMeta=["unit", "label"]) + entry = doc["parameters"]["params.my_param"] + assert entry["value"] == 123 + assert entry["unit"] == "M" + assert "label" in entry + + def test_locked_follower_saves_its_own_value_and_the_lock(tmp_path): pm = ParameterManager(name="params") pm.workingDirectory = tmp_path @@ -234,6 +258,28 @@ def test_legacy_simple_format_file_still_loads(tmp_path): assert pm.sp() == 7 +def test_legacy_flat_file_creates_a_globals_parameter_on_load(tmp_path): + """A legacy flat file holding a ``_globals.*`` key creates the Globals + parameter on load, through the internal creation path: create-on-load + is the intended behaviour for both file formats (D18; TEST_AUDIT.md, + "Profiles — loading Globals parameters").""" + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + + legacy_path = tmp_path / "legacy_globals.json" + legacy_path.write_text( + json.dumps({"params._globals.x.y": {"value": 1, "unit": "u"}}) + ) + pm.fromFile(str(legacy_path)) + + assert pm.has_param("_globals.x.y") + assert pm.get("_globals.x.y") == 1 + param = pm.parameter("_globals.x.y") + assert param.unit == "u" + assert isinstance(param, ManagedParameter) + assert param.path == "params._globals.x.y" + + def test_version_two_file_round_trips_through_from_file(tmp_path, monkeypatch): """A version-2 file written by toFile loads its parameters back — values and units — through fromFile.""" @@ -327,6 +373,8 @@ def test_invalid_document_is_refused_by_the_schema(): pm = ParameterManager(name="params") pm.add_parameter(name="a", initial_value=1, unit="u") pm.add_parameter(name="b", initial_value=2, unit="v") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") good = pm.toParamDict() lock_missing_locked = json.loads(json.dumps(good)) @@ -339,6 +387,26 @@ def test_invalid_document_is_refused_by_the_schema(): with pytest.raises(ValidationError): pm.fromParamDict(entry_without_value) + # a per-parameter key the v2 schema does not know is refused by the + # parameters.json leg of validateParameterManagerV2 + bad_vals = json.loads(json.dumps(good)) + bad_vals["parameters"]["params.a"]["vals"] = 42 + with pytest.raises(ValidationError): + pm.fromParamDict(bad_vals) + + # the top-level keys of the version-2 document are required + without_types = json.loads(json.dumps(good)) + del without_types["types"] + with pytest.raises(ValidationError): + pm.fromParamDict(without_types) + + # a Type entry carries exactly default, unit and target + type_entry_missing_target = json.loads(json.dumps(good)) + del type_entry_missing_target["types"]["qubit"]["parameters"]["IF"]["target"] + with pytest.raises(ValidationError): + pm.fromParamDict(type_entry_missing_target) + # the refused documents changed nothing assert pm.a() == 1 assert pm.b() == 2 + assert pm.list_types() == ["qubit"] From c384a708f6d29dcd3a6effe6ecc957caed1bbdfd Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 10:09:27 -0500 Subject: [PATCH 062/107] 4.1: history --- HISTORY_parameter_manager_redesign.md | 43 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 42a26f1..0940a1f 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -574,3 +574,46 @@ The Type API in `src/instrumentserver/params.py` now emits its own Broadcasts (D ### Process notes - plan-checker-glm's round-1 worker_done was rejected by Orca because of a garbled handle. After one nudge it resent, and a later duplicate was rejected as already settled, which did no harm. There were no stalls and no rejected permissions. + +## 4.1 Writer — 2026-09-25 + +`ParameterManager.toParamDict()` now returns the D19 version-2 profile document, `{"version": 2, "parameters": {...}, "types": {...}}`, and `toFile` writes it with `json.dump(..., indent=2, sort_keys=True)` as before. `parameters` is keyed by full paths and stores each parameter's own value (`ManagedParameter.own_value()`), its unit and, on Followers only, `lock: {target, locked}`. `types` stores every Type's entries (`default`, `unit`, full-form `target` or `null`) and its Nested Types. The new `schemas/parameter_manager_v2.json` and `serialize.validateParameterManagerV2` check the whole document and then run the `parameters` map through the unchanged `validateParamDict`. `fromParamDict` got a minimal reader shim so profile round trips keep working until 4.2. The new `test/pytest/test_pm_persistence.py` has 21 tests. + +### Commit by commit +- `8c5db87` The writer, the schema (plus `PM_V2_SCHEMA_PATH` in `instrumentserver/__init__.py`), `validateParameterManagerV2`, the reader shim and 19 tests. The orchestrator's readings in the coder spec set these rules: + - the signature `toParamDict(self, simpleFormat=False, includeMeta=["unit"])` stays (rule 7), and `simpleFormat` is documented as ignored + - `parameters` is built from `serialize.toParamDict([self], simpleFormat=False, ...)`, then own values are substituted and `lock` added. A locked Follower's snapshot reports the Target's value, which is why the substitution is needed. + - the shim: no `version` key means the legacy flat map, loaded exactly as before. `version: 2` is validated, then only `parameters` is loaded (value, unit, `deleteMissing`), and `lock` and `types` are ignored until 4.2. Any other version raises `ValueError` naming it. + - `validateParamDict`, `isSimpleFormat`, the Server's `serialize.fromParamDict`, `switch_to_profile` and `remove_all_parameters` stay untouched + + The new private `_create_managed_parameter` is the `_get_parent(..., create_parent=True)` + `_add_own_parameter` pair that `_ensure_global_target` used to inline. Both `_ensure_global_target` and the shim now call it, so a missing `_globals.*` parameter in a file gets created instead of hitting the 3.1 refusal in `add_parameter`. `test_param_manager.py` had five flat-shape assertions in three tests, and they now read `["parameters"][...]`. The schema is shipped by the existing `schemas/*.json` package-data glob, with no `pyproject.toml` change. The tests cover: + - the writer: version and keys, `simpleFormat` ignored, `test_locked_follower_saves_its_own_value_and_the_lock`, `locked: false`, no `lock` key without a Lock, a Globals parameter saved, the full `types` section, the full-form Type Lock Target, `types == {}` + - the file: `jsonschema.validate` against the new schema, and the file text equal to `json.dumps(doc, indent=2, sort_keys=True)` + - the shim: legacy flat and simple-format files, a version-2 round trip through `fromFile`, `test_globals_parameter_round_trips_through_the_internal_path`, `deleteMissing` both ways, the shim ignoring `lock`/`types` (without asserting they are not restored), the refusal of versions `3` and `"2"`, and two schema refusals + + Orchestrator run: ruff clean, 32 in the two named files, 437 in the full suite. +- `d36053c` Fix from round 0, five items: + - `test_include_meta_selects_the_per_parameter_metadata`: `includeMeta=[]` reduces an entry to `{"value": 123}` while a Follower keeps its `lock`, and `["unit", "label"]` adds `label`. No test had varied `includeMeta`, which rule 7 protects (test-reviewer-glm, should-fix). + - Three more cases in `test_invalid_document_is_refused_by_the_schema`. `vals: 42` passes the v2 schema and is refused only by `parameters.json`, so deleting that leg of `validateParameterManagerV2` now fails a test (both test reviewers, should-fix). A missing top-level `types` key (both test reviewers, nit, folded in). A Type entry without `target`. + - The shim's `_globals.` branch sits in the load loop both formats share, so a legacy flat file with a `_globals.*` key now creates the parameter where it used to raise. That departs from reading 3's "exactly today's behaviour" for legacy files. test-reviewer-qwen (should-fix) caught it. The orchestrator decided create-on-load is intended for both formats (D18, "saved") and pinned it with `test_legacy_flat_file_creates_a_globals_parameter_on_load`. The TEST_AUDIT row "Profiles — loading Globals parameters" went from `gap` to `covered`. + - The `types` side of the schema is now strict: `required` and `additionalProperties: false` on the per-Type and per-entry objects. reviewer-glm, reviewer-qwen and plan-checker-qwen raised it as a nit. It was kept because both reviewer models raised it and 4.2 builds Type loading on these keys. + - Two `gap` rows in `TEST_AUDIT.md` for older defects (rule 6). "Profiles — loading a file": `fromFile` never forwards `deleteMissing`, so the GUI's `deleteMissing=False` load runs with `True` (reviewer-glm and the coder). "Profiles — file validation": both schemas use `patternProperties` without `additionalProperties: false`, so a malformed parameter key passes validation (plan-checker-qwen). + + There were no source changes outside the schema. All six approved in re-review, and every raiser confirmed their item fixed. Orchestrator run: ruff clean, 34 in the two named files, 439 in the full suite. + +### Dropped findings +- The `toParamDict` docstring says values come from the cache and "never `get`". For a locked Follower, `snapshot_base` does call `get()` on the Target, though the saved own value is from the cache (reviewer-qwen, nit) → not sent. The wording is still in `params.py`. +- `test_locked_follower_saves_its_own_value_and_the_lock` checks `toParamDict()` output rather than reading the file back (plan-checker-glm, nit) → not sent. The file-text and schema tests write the same locked-Follower case to disk. +- Round 1 nits, none sent. The rewritten TEST_AUDIT row says every missing parameter goes through the internal path, when only `_globals.*` ones do (reviewer-glm, reviewer-qwen); it goes to the 4.2 coder spec. A test comment says `parameters.json` refuses unknown keys, but the `vals` case is refused by its type constraint (reviewer-glm). The per-Type `required` and both `additionalProperties: false` have no refusal case of their own (test-reviewer-glm). + +### Questions to Marcos +- The orchestrator flagged its coder-spec readings for Marcos: the v2 shape, `simpleFormat` kept but ignored, the new validator, the parameters-only shim with Globals created through the internal path, and the Server reader left alone. It also flagged the round-0 create-on-load decision for legacy files. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- For 4.2 (from reviewer-glm, recorded in `decisions.md`): loading a document while a parameter is locked in-session raises mid-loop on `set` (D6), with earlier entries already loaded. D20's order must unlock or clear Locks before values, or load own values through the cache. +- For 4.2: the shim ignores `lock` and `types`. The tests deliberately do not assert that they stay unrestored, so 4.2 can restore them without rewriting tests. The strict `types` schema is what 4.2's Type loading can rely on. +- `TEST_AUDIT.md`: "Profiles — loading a file" (`fromFile` drops `deleteMissing`) and "Profiles — file validation" (`patternProperties` gap), both `gap`. The wording of "Profiles — loading Globals parameters" is to be corrected in 4.2. + +### Process notes +- Two permissions were rejected in round 0. test-reviewer-qwen and reviewer-qwen each tried an inline `uv run python - <<'EOF'` heredoc that could not be read in full. Each reran its check as a scanned probe script in `orchestration/4.1/round-0/` and deleted it afterwards. +- The coder left a `mkdtemp` scratch folder under `orchestration/4.1/`, which the orchestrator removed. There were no stalls and no nudges. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 29942df..fa17f60 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -526,7 +526,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 4 — Persistence (version 2) -- [ ] **4.1 Writer.** `toParamDict`/`toFile` produce the D19 layout: `version: 2`, +- [x] **4.1 Writer.** `toParamDict`/`toFile` produce the D19 layout: `version: 2`, `parameters` (own values via `ManagedParameter.own_value()`, `unit`, `lock` only on Followers, full paths as keys), `types` (parameters with default/unit/target, nested). Keep `json.dump(..., indent=2, sort_keys=True)`. Update `serialize.validateParamDict` From b6ee0254f38ff35914862c0b919685afd8c59653 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 10:21:35 -0500 Subject: [PATCH 063/107] 4.2: read the version-2 profile document: up-front validation, D20 load order, diffed load broadcasts --- TEST_AUDIT.md | 2 +- src/instrumentserver/params.py | 432 +++++++++++-- .../fixtures/parameter_manager-legacy.json | 10 + test/pytest/test_pm_persistence.py | 578 +++++++++++++++++- 4 files changed, 975 insertions(+), 47 deletions(-) create mode 100644 test/pytest/fixtures/parameter_manager-legacy.json diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index b979151..3a2e54a 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -37,7 +37,7 @@ States: | client.md | Parameter snapshots | Relative Client-side paths, selected and all-instrument save/restore, flat and nested shapes, and nested `setParameters` rejection match the guide. Native JSON booleans still become `0.0`/`1.0` and fail QCoDeS Boolean validation | `section_save_and_restore_parameter_values` in `verify_client.py` | covered | `test_parameter_snapshot_files_and_current_boolean_limitation` covers the end-to-end workflow and explicitly references open product bug #152. The issue remains open and is not fixed in this documentation pass | | client.md | Errors and timeouts | Server validation failures arrive as generic `Exception` objects. A timeout discards the old socket, connects a replacement without retrying, lets the original Server call finish exactly once, and permits later requests. `raise_exceptions=False` logs and returns `None`; `disconnect()` is permanent | `section_handle_errors_and_timeouts` in `verify_client.py` | covered | `test_server_errors_timeout_socket_replacement_and_quiet_mode` and `test_timeout_dummy_responds_to_idn` | | user_guide/parameter_manager.md (future) | Types — `get_type` through a proxy | `bluePrintToDict` stringifies scalar leaves and `deserialize_obj` re-parses them numerically, so a Type entry `default` such as the string `"10"` comes back as the int `10` over the wire | Found during the plan 2.1 review; reproduced by round-tripping a `PMTypeBluePrint` through `bluePrintToDict`/`deserialize_obj` | gap | Pre-existing wire-format limitation shared with every blueprint payload (e.g. `PMLockBluePrint`, `ParameterBroadcastBluePrint.value`); not introduced by 2.1; noted per plan rule 6, not fixed here | -| user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create every missing parameter through the internal creation path (`_create_managed_parameter`, which the public `add_parameter` refusal of the `_globals` name bypasses), so a profile file holding a `_globals.*` key creates the Globals parameter on load in both the legacy flat map and the version-2 document (create-on-load is intended for both, D18) | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)`; covered in 4.1 | covered | `test_legacy_flat_file_creates_a_globals_parameter_on_load` and `test_globals_parameter_round_trips_through_the_internal_path` in `test/pytest/test_pm_persistence.py` | +| user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create a missing `_globals.*` parameter through the internal creation path (`_create_managed_parameter`, which the public `add_parameter` refusal of the `_globals` name bypasses), while every other missing parameter is created through the ordinary `add_parameter`; a profile file holding a `_globals.*` key creates the Globals parameter on load in both the legacy flat map and the version-2 document (create-on-load is intended for both, D18) | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)`; covered in 4.1, wording corrected in 4.2 | covered | `test_legacy_flat_file_creates_a_globals_parameter_on_load` and `test_globals_parameter_round_trips_through_the_internal_path` in `test/pytest/test_pm_persistence.py` | | user_guide/parameter_manager.md (future) | Locks — removing a parameter | `ParameterManager.remove_parameter` raises `KeyError()` when the parameter itself is missing but `ValueError` (from `_get_parent`) when an intermediate Parameter Group is missing, while `_get_param` raises `ValueError` for both; the 1.2/3.3 tests pin both types | Found during the plan 3.3 review (reviewer-qwen) | gap | Pre-existing since task 1.2; not changed per plan rule 6; if revisited, raise `ValueError` naming the full path and update the tests asserting `KeyError` | | user_guide/parameter_manager.md (future) | Profiles — loading a file | `ParameterManager.fromFile` accepts `deleteMissing` but never forwards it to `fromParamDict`, so the GUI's `fromFile(filePath=..., deleteMissing=False)` runs with the default `True` | Found during the plan 4.1 review (reviewer-glm, the coder) | gap | Pre-existing; not changed per plan rule 6; fix is a one-line forward plus a test | | user_guide/parameter_manager.md (future) | Profiles — file validation | Both `schemas/parameters.json` and `schemas/parameter_manager_v2.json` use `patternProperties` without `additionalProperties: false`, so a parameter key that does not match `^(\w+)(\.\w+)*$` (e.g. with a space) passes validation and fails later in the loader | Found during the plan 4.1 review (plan-checker-qwen) | gap | Pre-existing in the legacy schema the plan protects; not changed per plan rule 6 | diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 61a6001..5fbbff8 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -937,11 +937,18 @@ def followers_of(self, name: str) -> "List[str]": # ``types_of``) and failed validations emit nothing. # ------------------------------------------------------------------ - def _require_type(self, name: str) -> "_TypeDefinition": + def _require_type( + self, name: str, types: "Dict[str, _TypeDefinition] | None" = None + ) -> "_TypeDefinition": """The registry entry of the Type ``name``; raises ``ValueError`` - naming the name when no such Type exists.""" + naming the name when no such Type exists. ``types`` defaults to + the Type registry; the version-2 document reader passes the + candidate registry built from the document, so the expansion + helpers can run on it without touching the real one.""" + if types is None: + types = self._types try: - return self._types[name] + return types[name] except KeyError: raise ValueError(f"no Type named '{name}' exists") from None @@ -1045,22 +1052,29 @@ def _effective_parameters(self, type_name: str) -> Dict[str, Dict[str, str]]: for path, (entry, from_type) in expanded.items() } - def _expand_effective(self, type_name: str) -> Dict[str, Tuple["_TypeEntry", str]]: + def _expand_effective( + self, + type_name: str, + types: "Dict[str, _TypeDefinition] | None" = None, + ) -> Dict[str, Tuple["_TypeEntry", str]]: """The effective parameter set of the Type ``type_name`` in raw form: every expanded path mapped to the :class:`_TypeEntry` that defines it and the name of the Type defining it. Raises the same errors as :meth:`_effective_parameters` (unknown Type, a cycle, - a Nested Type missing from the registry, a path appearing twice).""" - definition = self._require_type(type_name) + a Nested Type missing from the registry, a path appearing twice). + ``types`` defaults to the Type registry; the version-2 document + reader passes the candidate registry built from the document, the + way :meth:`add_nested_type` validates a candidate.""" + definition = self._require_type(type_name, types) # cycles first: the expansion below would not terminate - cycle = self._nested_cycle(definition) + cycle = self._nested_cycle(definition, types=types) if cycle is not None: raise ValueError(f"cycle in nested Types: {' -> '.join(cycle)}") # the cycle walk visited every Nested Type of the closure, so all # lookups below are known to exist expanded: Dict[str, Tuple[_TypeEntry, str]] = {} duplicated: List[str] = [] - self._collect_effective(definition, "", expanded, duplicated) + self._collect_effective(definition, "", expanded, duplicated, types=types) if duplicated: paths = ", ".join(f"'{path}'" for path in sorted(duplicated)) raise ValueError( @@ -1120,12 +1134,16 @@ def _collect_effective( prefix: str, effective: Dict[str, Tuple[_TypeEntry, str]], duplicated: List[str], + types: "Dict[str, _TypeDefinition] | None" = None, ) -> None: """Add every entry of ``definition`` — and, recursively, of its Nested Types under their submodule names — to ``effective`` as ``(entry, defining Type name)`` pairs, recording every path that appears more than once in ``duplicated`` instead of raising, so - one error can name them all.""" + one error can name them all. ``types`` defaults to the Type + registry; see :meth:`_expand_effective`.""" + if types is None: + types = self._types for path, entry in definition.parameters.items(): full_path = f"{prefix}{path}" if full_path in effective: @@ -1134,10 +1152,11 @@ def _collect_effective( effective[full_path] = (entry, definition.name) for submodule, nested_name in definition.nested.items(): self._collect_effective( - self._types[nested_name], + types[nested_name], f"{prefix}{submodule}.", effective, duplicated, + types=types, ) # ------------------------------------------------------------------ @@ -2444,7 +2463,7 @@ def fromFile( filePath: str | None = None, deleteMissing: bool = True, ) -> None: - """Load parameters from a parameter json file + """Load parameters, Types and Locks from a parameter json file (see :mod:`.serialize`). If the filepath starts with 'parameter_manager-' and ends with '.json', @@ -2483,29 +2502,72 @@ def fromFile( def fromParamDict( self, paramDict: Dict[str, Any], deleteMissing: bool = True ) -> None: - """Load parameters from a parameter dictionary (see :mod:`.serialize`). + """Load parameters, Types and Locks from a parameter dictionary + (see :mod:`.serialize`). A dictionary without a top-level ``version`` key is the legacy flat - parameter map and loads exactly as before. A version-2 profile - document (the document :meth:`toParamDict` writes, plan decision - D19) is validated as a whole with - :func:`serialize.validateParameterManagerV2`, and its - ``parameters`` map is then loaded with the same semantics as the - legacy flat map: value, unit and ``deleteMissing``. Its ``lock`` - entries and ``types`` section are ignored for now — task 4.2 loads - the Targets, the Types and the Locks in D20's order. Any other - ``version`` value raises ``ValueError`` naming it. - - A parameter the file asks for that does not exist yet is created; - one under the Globals submodule is created through the internal - creation path (:meth:`_create_managed_parameter`), since the - public :meth:`add_parameter` refuses the Globals name (D18), so a - saved Globals parameter round-trips. + parameter map and loads exactly as before: parameters only, and the + Types and Locks of this Parameter Manager are untouched by the + legacy reader. + + A version-2 profile document (the document :meth:`toParamDict` + writes, plan decision D19) is validated as a whole before anything + is loaded (plan decision D20). The schema is checked with + :func:`serialize.validateParameterManagerV2`; every remaining + problem is collected into one ``ValueError`` naming all of them + (rule 3): every file key must belong to this Parameter Manager + (the full-path form with the instrument name), every Type name + must be valid (non-empty, not the reserved Globals name + ``_globals``), every Nested Type must name a Type of the document, + the document's Types must form no Nested Type cycle and hold no + effective parameter path twice, every ``lock`` Target and every + non-null Type Lock Target must be a ``parameters`` key of the + document, and no ``lock`` may be a self-lock or close a cycle + among the document's Locks (walking Targets regardless of + locked/unlocked state, D7). A refused document changes nothing and + emits nothing. Any other ``version`` value raises ``ValueError`` + naming it. + + After the validation the document loads in order (D20): first the + Locks of every parameter it lists go away, so setting a stored own + value on a currently locked Follower cannot raise (D6); then the + parameters with the semantics the reader always had (an existing + one is set to its stored own value and unit, a missing one is + created — one under the Globals submodule through the internal + creation path :meth:`_create_managed_parameter`, since the public + :meth:`add_parameter` refuses the Globals name (D18) — and + ``deleteMissing=True`` removes the parameters the document does + not list, dropping their Locks and Type Locks exactly like + :meth:`remove_parameter` does); then the Types, written straight + into the registry as definitions — with ``deleteMissing=True``, + every Type the document does not define is removed — with **no** + Instance side effects (D20): no parameter is created for an + Instance and no Type Lock is applied on load, so a partial + Instance stays partial and an Instance's Locks come only from the + document's ``lock`` entries; then the Locks, each set directly to + the stored full-form Target and stored locked state (like + :meth:`lock` creates it after the cycle check, not through a + ``lock()``/``unlock()`` pair). A parameter the document lists + without a ``lock`` entry ends with no Lock; parameters it does not + list keep theirs when ``deleteMissing=False``. + + The Broadcasts go out once, after the whole load succeeded (D22, + D10): one ``pm-type-update`` per Type the document wrote, then one + with a ``None`` payload per Type removed, then one + ``pm-lock-update`` per Lock that ended different from before the + load (``None`` when it was removed), in tree order. The load + emits nothing for the parameter values it sets, and it re-emits + **no** ``parameter-creation``/``parameter-deletion`` Broadcasts + for the parameters it creates and removes: the Server's + literal-name detection does not see them either, so a GUI watching + this Parameter Manager must refresh its structure after a profile + load. :param paramDict: Parameter dictionary — a legacy flat map or a version-2 profile document. - :param deleteMissing: If ``True``, delete parameters currently in the - ParameterManager that are not listed in the file. + :param deleteMissing: If ``True``, delete parameters currently in + the ParameterManager that are not listed in the file, and + remove the Types the file does not define. """ if "version" in paramDict: version = paramDict["version"] @@ -2515,14 +2577,13 @@ def fromParamDict( f"{version!r} (this reader reads version 2 and the " "legacy flat map, which carries no version key)" ) - serialize.validateParameterManagerV2(paramDict) - paramDict = paramDict["parameters"] - simple = False - else: - # legacy flat parameter map: exactly the behaviour before the - # version-2 profile document existed - serialize.validateParamDict(paramDict) - simple = serialize.isSimpleFormat(paramDict) + self._load_v2_document(paramDict, deleteMissing) + return + + # legacy flat parameter map: exactly the behaviour before the + # version-2 profile document existed + serialize.validateParamDict(paramDict) + simple = serialize.isSimpleFormat(paramDict) currentParams = self.list() fileParams = [ @@ -2559,6 +2620,303 @@ def fromParamDict( if pn not in fileParams and deleteMissing: self.remove_parameter(pn) + def _collect_v2_document_problems(self, document: Dict[str, Any]) -> List[str]: + """Collect every validation problem of a version-2 profile + document into one list of messages, so a refused document can be + named in full (rule 3, D20). The schema leg has already run when + this is called; nothing here changes any state.""" + problems: List[str] = [] + parameters = document["parameters"] + document_types = document["types"] + prefix = f"{self.name}." + + # every file key belongs to this Parameter Manager: the full + # dotted form with the instrument name, like the writer stores it + for key in parameters: + if not key.startswith(prefix): + problems.append( + f"parameter key '{key}' does not belong to this " + f"Parameter Manager ('{self.name}')" + ) + + # every Type name is valid: non-empty, not the reserved Globals + # submodule name (D18) + for type_name in document_types: + if type_name == "_globals": + problems.append( + f"'{type_name}' is not a valid Type name: " + "the Globals submodule name is reserved" + ) + elif not type_name: + problems.append("'' is not a valid Type name: it is empty") + + # the candidate registry built from the document, the way + # add_nested_type validates a candidate: the expansion helpers run + # on it without touching the real registry + candidates = { + type_name: _TypeDefinition( + name=type_name, + parameters={ + path: _TypeEntry( + default=entry["default"], + unit=entry["unit"], + target=entry["target"], + ) + for path, entry in spec["parameters"].items() + }, + nested=dict(spec["nested"]), + ) + for type_name, spec in document_types.items() + } + + # every Nested Type names a Type of the document + for type_name, definition in candidates.items(): + for nested_name in definition.nested.values(): + if nested_name not in candidates: + problems.append( + f"Type '{type_name}' nests '{nested_name}', which " + "is not among the document's Types" + ) + + def closure_complete(name: str) -> bool: + seen: set = set() + stack = [name] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + candidate = candidates.get(current) + if candidate is None: + return False + stack.extend(candidate.nested.values()) + return True + + # no Nested Type cycle and no effective path twice, per the + # expansion helpers on the candidate registry; Types whose Nested + # Type closure the document does not define completely are skipped, + # the missing names are reported above + reported: set = set() + for type_name in candidates: + if not closure_complete(type_name): + continue + try: + self._expand_effective(type_name, types=candidates) + except ValueError as exc: + message = str(exc) + if message not in reported: + reported.add(message) + problems.append(message) + + # every Lock Target and every non-null Type Lock Target is a + # parameters key of the document (D20); every missing Target is + # named with everything that refers to it + missing_targets: Dict[str, List[str]] = {} + for key, entry in parameters.items(): + lock = entry.get("lock") + if lock is not None and lock["target"] not in parameters: + missing_targets.setdefault(lock["target"], []).append( + f"the Lock on Follower '{key}'" + ) + for type_name, spec in document_types.items(): + for path, entry in spec["parameters"].items(): + target = entry["target"] + if target is not None and target not in parameters: + missing_targets.setdefault(target, []).append( + f"the Type Lock on '{type_name}.{path}'" + ) + for target, referees in missing_targets.items(): + problems.append( + f"Target '{target}' is not a parameter of the document: " + f"referred to by {', '.join(referees)}" + ) + + # no self-lock and no cycle among the document's Locks: the walk + # follows each Target's Lock within the document regardless of + # locked/unlocked state (D7) + document_locks = { + key: entry["lock"] + for key, entry in parameters.items() + if "lock" in entry + } + for follower_full, lock in document_locks.items(): + target_full = lock["target"] + if target_full == follower_full: + problems.append(f"cannot lock {follower_full} to itself") + continue + chain = [target_full] + seen = {target_full} + current = target_full + while True: + next_lock = document_locks.get(current) + if next_lock is None: + break + nxt = next_lock["target"] + if nxt == follower_full or nxt in seen: + problems.append( + f"cannot lock {follower_full} to {target_full}: " + f"cycle in Lock targets: {' -> '.join(chain + [nxt])}" + ) + break + seen.add(nxt) + chain.append(nxt) + current = nxt + + return problems + + def _load_v2_document(self, document: Dict[str, Any], deleteMissing: bool) -> None: + """Validate a version-2 profile document as a whole and load it in + D20's order: the Locks of the listed parameters, the parameters, + the Types without Instance side effects, the Locks — and emit the + load's Broadcasts once, after it succeeded (D22, D10).""" + serialize.validateParameterManagerV2(document) + problems = self._collect_v2_document_problems(document) + if problems: + raise ValueError( + "invalid version-2 Parameter Manager profile document: " + + "; ".join(problems) + ) + + parameters = document["parameters"] + document_types = document["types"] + file_params = [key[len(self.name) + 1 :] for key in parameters] + file_param_set = set(file_params) + # the pre-load Lock state, for the diff the load reports at the end + previous_locks = self.list_locks() + + # The methods reused below announce their own state changes + # (remove_parameter drops Locks and clears Type Locks with one + # Broadcast each, D10/D22). The load reports the whole transition + # itself, once, after it succeeded, so the reused methods run with + # the sinks detached; the sinks are back before the load's own + # Broadcasts go out. + saved_sinks = self._broadcast_sinks + self._broadcast_sinks = [] + try: + # (a) the Locks of every parameter the document lists go + # first: setting a stored own value on a currently locked + # Follower would raise otherwise (D6); the Locks are + # re-created from the document in step (d) + for pn in file_params: + if self.has_param(pn): + param = self.parameter(pn) + if isinstance(param, ManagedParameter) and param.lock is not None: + param.lock = None + param._target = None + + # (b) the parameters, with the semantics the reader always had + current_params = self.list() + for pn in file_params: + entry = parameters[f"{self.name}.{pn}"] + val = entry["value"] + unit = entry.get("unit", "") + + if self.has_param(pn): + self.parameter(pn)(val) + if unit is not None: + param = self.parameter(pn) + assert hasattr(param, "unit") + param.unit = unit + + elif pn.startswith("_globals."): + # the public add_parameter refuses the Globals name + # (D18); a saved Globals parameter round-trips through + # the internal creation path + self._create_managed_parameter(pn, val, unit) + + else: + self.add_parameter(pn, initial_value=val, unit=unit) + + if deleteMissing: + for pn in current_params: + if pn not in file_param_set and deleteMissing: + self.remove_parameter(pn) + + # (c) the Types, written straight into the registry with no + # Instance side effects (D20): no parameter is created for an + # Instance and no Type Lock is applied on load + written_types: List[str] = [] + removed_types: List[str] = [] + if deleteMissing: + for type_name in list(self._types): + if type_name not in document_types: + del self._types[type_name] + removed_types.append(type_name) + for type_name, spec in document_types.items(): + self._types[type_name] = _TypeDefinition( + name=type_name, + parameters={ + path: _TypeEntry( + default=entry["default"], + unit=entry["unit"], + target=entry["target"], + ) + for path, entry in spec["parameters"].items() + }, + nested=dict(spec["nested"]), + ) + written_types.append(type_name) + + # (d) the Locks, each set directly to the stored full-form + # Target and stored locked state — like lock() creates it + # after the cycle check, not through a lock()/unlock() pair + for pn in file_params: + lock_entry = parameters[f"{self.name}.{pn}"].get("lock") + if lock_entry is None: + continue + param = self.parameter(pn) + if not isinstance(param, ManagedParameter): + raise ValueError(f"{self._full_path(pn)} cannot carry a Lock") + follower_full = self._full_path(pn) + target_full = lock_entry["target"] + self._check_lock_allowed(follower_full, target_full) + target_param = self._param_by_full_path(target_full) + assert target_param is not None, ( + "the validated Target is not a parameter of this " + "Parameter Manager" + ) + param._target = target_param + param.lock = PMLockBluePrint( + target=target_full, locked=lock_entry["locked"] + ) + finally: + self._broadcast_sinks = saved_sinks + + # the load's Broadcasts, after the whole load succeeded (D22, + # D10): one pm-type-update per Type written, one with a None + # payload per Type removed, then one pm-lock-update per Lock that + # ended different from before the load, in tree order. Values set + # during the load and the created and removed parameters emit + # nothing (see fromParamDict). + for type_name in written_types: + self._broadcast_type_update(type_name) + for type_name in removed_types: + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{type_name}", + action=PM_TYPE_UPDATE, + value=None, + ) + ) + after_locks = self.list_locks() + tree_paths = [rel_path for rel_path, _ in self._iter_params()] + tree_set = set(tree_paths) + ordered_paths = [ + rel_path + for rel_path in tree_paths + if rel_path in previous_locks or rel_path in after_locks + ] + # a Follower whose parameter the load removed is no longer in the + # tree; its Lock still ended removed and is reported last + ordered_paths += [ + rel_path for rel_path in previous_locks if rel_path not in tree_set + ] + for rel_path in ordered_paths: + before = previous_locks.get(rel_path) + after = after_locks.get(rel_path) + if before != after: + self._broadcast_lock_update(rel_path, after) + def toParamDict( self, simpleFormat: bool = False, includeMeta: List[str] = ["unit"] ) -> Dict[str, Any]: diff --git a/test/pytest/fixtures/parameter_manager-legacy.json b/test/pytest/fixtures/parameter_manager-legacy.json new file mode 100644 index 0000000..e64e00f --- /dev/null +++ b/test/pytest/fixtures/parameter_manager-legacy.json @@ -0,0 +1,10 @@ +{ + "parameter_manager.my_param": { + "value": 123, + "unit": "M" + }, + "parameter_manager.nested_param.child": { + "value": 456, + "unit": "a" + } +} diff --git a/test/pytest/test_pm_persistence.py b/test/pytest/test_pm_persistence.py index a51015a..cea09b1 100644 --- a/test/pytest/test_pm_persistence.py +++ b/test/pytest/test_pm_persistence.py @@ -1,27 +1,39 @@ -"""Tests for the Parameter Manager's version-2 persistence (plan task 4.1). +"""Tests for the Parameter Manager's version-2 persistence (plan tasks +4.1 and 4.2). The writer part checks the document :meth:`ParameterManager.toParamDict` produces (plan decision D19): ``version`` 2, ``parameters`` keyed by full dotted paths with the own values, the per-Follower ``lock`` entries and the ``types`` section, the file dump format, and validity against -``schemas/parameter_manager_v2.json``. The reader part checks the -:meth:`ParameterManager.fromParamDict` shim: the legacy flat map still -loads, a version-2 document loads its parameters back (Globals included, -``deleteMissing`` semantics unchanged) while its ``lock`` entries and -``types`` section are ignored for now, and unsupported versions or invalid -documents are refused. All tests are unit tests on a local Parameter -Manager with no Server involved. +``schemas/parameter_manager_v2.json``. The reader part checks +:meth:`ParameterManager.fromParamDict`/:meth:`ParameterManager.fromFile`: +the legacy flat map still loads (parameters only), a version-2 document is +validated as a whole — refused with every problem named, state untouched — +and then loads in D20's order (the Locks of the listed parameters, the +parameters, the Types without Instance side effects, the Locks), with the +load's Broadcasts emitted once after it succeeded. All tests are unit tests +on a local Parameter Manager with no Server involved. """ import json import re +from pathlib import Path import pytest from jsonschema import ValidationError, validate from instrumentserver import PM_V2_SCHEMA_PATH +from instrumentserver.blueprints import ( + PARAMETER_CREATION, + PARAMETER_DELETION, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PMLockBluePrint, +) from instrumentserver.params import ManagedParameter, ParameterManager +FIXTURES = Path(__file__).parent / "fixtures" + def make_populated_manager(name="params"): """A local Parameter Manager with a nested parameter and Types with @@ -228,7 +240,7 @@ def test_file_is_dumped_with_indent_two_and_sorted_keys(tmp_path): # --------------------------------------------------------------------------- -# Reader shim: legacy flat map, version-2 parameters, refusals +# Reader shim (4.1): legacy flat map, version-2 parameters, refusals # --------------------------------------------------------------------------- @@ -410,3 +422,551 @@ def test_invalid_document_is_refused_by_the_schema(): assert pm.a() == 1 assert pm.b() == 2 assert pm.list_types() == ["qubit"] + + +# --------------------------------------------------------------------------- +# Reader (4.2): the version-2 load in D20's order +# --------------------------------------------------------------------------- + + +def make_lock_and_type_manager(name="params"): + """A Parameter Manager exercising the whole version-2 document: Types + (one nesting another), Type Locks with the default Globals Target and + an explicit Target, Locks locked and unlocked, a chain, own values + differing from the Target values, and a Globals parameter. The Types + are completed before the parameters exist, so no submodule carries + the whole shape and none is an Instance (D12).""" + pm = ParameterManager(name=name) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + # the Type Lock with the default Globals Target, which creates the + # Globals parameter on demand + pm.lock_type_parameter("qubit", "octave_gain") + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.add_parameter(name="q02.IF", initial_value=101735238.0, unit="Hz") + pm.add_parameter(name="q03.sp", initial_value=1.5, unit="V") + # the Type Lock with an explicit Target + pm.lock_type_parameter("qubit", "IF", target="q01Data.IF") + # a locked chain q03.sp -> q02.IF -> q01.IF -> q01Data.IF, with the + # last hop unlocked, and own values differing from the Target's + pm.lock("q01.IF", "q01Data.IF") + pm.lock("q02.IF", "q01.IF") + pm.lock("q03.sp", "q02.IF") + pm.unlock("q03.sp") + return pm + + +def test_legacy_fixture_file_loads_values_and_units(): + """The checked-in legacy fixture (a flat map with a nested path and a + unit) loads through fromFile with values and units.""" + pm = ParameterManager(name="parameter_manager") + pm.fromFile(str(FIXTURES / "parameter_manager-legacy.json")) + + assert pm.my_param() == 123 + assert pm.my_param.unit == "M" + assert pm.nested_param.child() == 456 + assert pm.nested_param.child.unit == "a" + + +def test_legacy_load_leaves_types_and_locks_untouched(): + """The legacy flat map loads parameters only. The locked parameter is + not listed (a legacy load sets every listed parameter, and a locked + Follower refuses set, D6), so with ``deleteMissing=False`` the load + touches neither the Lock nor the Types.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("a", "b") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + pm.fromParamDict({"params.b": {"value": 22, "unit": "v"}}, deleteMissing=False) + + assert pm.get("b") == 22 + assert pm.parameter("a").own_value() == 1 + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + assert pm.list_types() == ["qubit"] + + +def test_version_two_document_round_trips_exactly(tmp_path): + """A manager with Types, Type Locks, Locks in both states, a chain and + a Globals parameter saves, loads into a fresh manager, and both + produce the same document; the loaded Followers pull on get and keep + their own values.""" + pm = make_lock_and_type_manager() + pm.toFile(str(tmp_path / "parameter_manager-rt.json")) + + pm2 = ParameterManager(name="params") + pm2.fromFile(str(tmp_path / "parameter_manager-rt.json")) + + assert pm2.toParamDict() == pm.toParamDict() + # no Instance side effects: q01 carries only IF and is not completed + # into an Instance by the loaded Type (D20) + assert not pm2.has_param("q01.octave_gain") + assert pm2.instances_of("qubit") == [] + # pull on get through the chain: q03.sp is unlocked (its own value), + # q02.IF is locked and reads q01.IF, which is locked and reads the + # Target q01Data.IF (D7) + assert pm2.get("q02.IF") == 42e6 + assert pm2.parameter("q02.IF").own_value() == 101735238.0 + assert pm2.get("q03.sp") == 1.5 + assert pm2.get_lock("q03.sp") == PMLockBluePrint( + target="params.q02.IF", locked=False + ) + assert pm2.get_lock("q01.IF") == PMLockBluePrint( + target="params.q01Data.IF", locked=True + ) + + +def test_missing_targets_are_all_named_and_change_nothing(): + """A document whose Lock Target and Type Lock Target point at absent + keys is refused with every missing Target named — both kinds, in one + message — leaving the previous state intact and emitting nothing.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.lock("a", "b") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + before_list = pm.list() + before_values = {path: pm.get(path) for path in before_list} + before_locks = pm.list_locks() + before_types = pm.list_types() + before_type = pm.get_type("qubit") + + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 10, + "unit": "u", + "lock": {"target": "params.ghost1", "locked": True}, + }, + "params.b": {"value": 20, "unit": "v"}, + "params.q01.IF": {"value": 2e9, "unit": "Hz"}, + }, + "types": { + "qubit": { + "parameters": { + "IF": { + "default": None, + "unit": "Hz", + "target": "params.ghost2", + } + }, + "nested": {}, + } + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert "params.ghost1" in message + assert "params.ghost2" in message + assert "the Lock on Follower 'params.a'" in message + assert "the Type Lock on 'qubit.IF'" in message + + # the previous state is untouched and nothing was emitted + assert pm.list() == before_list + assert {path: pm.get(path) for path in before_list} == before_values + assert pm.list_locks() == before_locks + assert pm.list_types() == before_types + assert pm.get_type("qubit") == before_type + assert received == [] + + +def test_nested_types_missing_from_the_document_are_refused_up_front(): + """A Nested Type that no Type of the document defines is refused + before anything is loaded, with every offender named.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": {}, + "types": { + "qubit": {"parameters": {}, "nested": {"readout": "ghost_readout"}}, + "pulse": {"parameters": {}, "nested": {"win": "ghost_window"}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert "ghost_readout" in message + assert "ghost_window" in message + assert pm.list() == [] + assert pm.list_types() == [] + + +def test_a_nested_type_cycle_in_the_document_is_refused_up_front(): + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": {}, + "types": { + "qubit": {"parameters": {}, "nested": {"readout": "readout"}}, + "readout": {"parameters": {}, "nested": {"qubit": "qubit"}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + assert "qubit -> readout -> qubit" in str(excinfo.value) + assert pm.list_types() == [] + + +def test_a_duplicated_effective_path_in_the_document_is_refused_up_front(): + """A Type whose own entry collides with a Nested Type's entry in its + effective set is refused before anything is loaded.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": {}, + "types": { + "readout": { + "parameters": { + "power": {"default": -10, "unit": "dBm", "target": None} + }, + "nested": {}, + }, + "qubit": { + "parameters": { + "readout.power": { + "default": None, + "unit": "dBm", + "target": None, + } + }, + "nested": {"readout": "readout"}, + }, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + assert "readout.power" in str(excinfo.value) + assert pm.list_types() == [] + + +def test_a_lock_cycle_in_the_document_is_refused_up_front(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("a", "b") # the in-session Lock the refused load must not disturb + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.b", "locked": True}, + }, + "params.b": { + "value": 2, + "unit": "v", + "lock": {"target": "params.a", "locked": True}, + }, + }, + "types": {}, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert "params.a" in message + assert "params.b" in message + assert "cycle in Lock targets" in message + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + assert pm.get_lock("b") is None + + +def test_a_self_lock_in_the_document_is_refused_up_front(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.a", "locked": True}, + } + }, + "types": {}, + } + with pytest.raises(ValueError, match="cannot lock params.a to itself"): + pm.fromParamDict(doc) + + assert pm.get_lock("a") is None + + +def test_a_key_of_another_instrument_is_refused_up_front(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + doc = { + "version": 2, + "parameters": {"other.a": {"value": 1, "unit": "u"}}, + "types": {}, + } + with pytest.raises( + ValueError, match="does not belong to this Parameter Manager" + ): + pm.fromParamDict(doc) + + assert pm.a() == 1 + + +def test_partial_instances_are_not_completed_and_get_no_type_lock(): + """The Types load with no Instance side effects (D20): a submodule + carrying one entry of a two-entry Type is not completed, is no + Instance, and gets no Lock from the entry's Type Lock.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": { + "params._globals.qubit.IF": {"value": 1e9, "unit": "Hz"}, + "params.q01.IF": {"value": 2e9, "unit": "Hz"}, + }, + "types": { + "qubit": { + "parameters": { + "IF": { + "default": None, + "unit": "Hz", + "target": "params._globals.qubit.IF", + }, + "gain": {"default": 10, "unit": "dB", "target": None}, + }, + "nested": {}, + } + }, + } + pm.fromParamDict(doc) + + assert pm.list_types() == ["qubit"] + assert not pm.has_param("q01.gain") + assert pm.instances_of("qubit") == [] + # the Type Lock is stored on the entry but not applied on load + assert pm.get_lock("q01.IF") is None + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "params._globals.qubit.IF" + ) + + +def test_a_listed_parameter_without_a_lock_entry_loses_its_lock(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("a", "b") + doc = { + "version": 2, + "parameters": { + "params.a": {"value": 1, "unit": "u"}, + "params.b": {"value": 2, "unit": "v"}, + }, + "types": {}, + } + pm.fromParamDict(doc, deleteMissing=False) + + assert pm.get_lock("a") is None + assert pm.list_locks() == {} + + +def test_a_locked_follower_loads_its_own_value_and_the_files_lock_state(): + """Setting the stored own value on a currently locked Follower cannot + raise (D6): the listed Locks go first, and the Follower ends with the + file's Lock state.""" + pm = ParameterManager(name="params") + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q01Data.IF", initial_value=2.0, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + doc = { + "version": 2, + "parameters": { + "params.q01.IF": { + "value": 9.0, + "unit": "Hz", + "lock": {"target": "params.q01Data.IF", "locked": False}, + }, + "params.q01Data.IF": {"value": 2.0, "unit": "Hz"}, + }, + "types": {}, + } + pm.fromParamDict(doc) + + assert pm.parameter("q01.IF").own_value() == 9.0 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="params.q01Data.IF", locked=False + ) + assert pm.get("q01.IF") == 9.0 + + +def make_deletion_manager(name="params"): + pm = ParameterManager(name=name) + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.add_parameter("c", initial_value=3, unit="w") + pm.add_parameter("d", initial_value=4, unit="x") + pm.lock("c", "a") # c is not in the document: its Lock goes with it + pm.lock("a", "d") # re-created from the document with a new Target + pm.add_type("kept") + pm.add_type_parameter("kept", "k", default=1, unit="u") + pm.add_type("dropped") + pm.add_type_parameter("dropped", "x", default=2, unit="v") + return pm + + +DELETE_MISSING_DOC = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.b", "locked": True}, + }, + "params.b": {"value": 2, "unit": "v"}, + }, + "types": { + "kept": { + "parameters": {"k": {"default": 1, "unit": "u", "target": None}}, + "nested": {}, + } + }, +} + + +def test_delete_missing_removes_absent_parameters_types_and_locks(): + pm = make_deletion_manager() + received = [] + pm.add_broadcast_sink(received.append) + pm.fromParamDict(DELETE_MISSING_DOC, deleteMissing=True) + + assert not pm.has_param("c") + assert not pm.has_param("d") + assert pm.list_types() == ["kept"] + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + assert pm.list_locks() == {"a": PMLockBluePrint(target="params.b", locked=True)} + # the load's Broadcasts: one pm-type-update per Type written, one with + # None per Type removed, then one pm-lock-update per Lock that ended + # different (c's Lock ended removed with its parameter) + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.kept"), + (PM_TYPE_UPDATE, "params.dropped"), + (PM_LOCK_UPDATE, "params.a"), + (PM_LOCK_UPDATE, "params.c"), + ] + assert received[0].value is not None + assert received[1].value is None + assert received[2].value == PMLockBluePrint(target="params.b", locked=True) + assert received[3].value is None + + +def test_delete_missing_false_keeps_absent_parameters_types_and_locks(): + pm = make_deletion_manager() + received = [] + pm.add_broadcast_sink(received.append) + pm.fromParamDict(DELETE_MISSING_DOC, deleteMissing=False) + + assert pm.has_param("c") + assert pm.has_param("d") + assert sorted(pm.list_types()) == ["dropped", "kept"] + # c keeps its Lock; a's Lock is re-created from the document + assert pm.get_lock("c") == PMLockBluePrint(target="params.a", locked=True) + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + # nothing was removed, so no None payload went out; c's unchanged Lock + # emits nothing + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.kept"), + (PM_LOCK_UPDATE, "params.a"), + ] + + +def test_one_load_emits_type_updates_then_lock_updates_and_nothing_for_values(): + """The Broadcasts of one load, in order: the pm-type-updates of the + Types written, then the pm-lock-updates of the Locks that ended + different, in tree order — and nothing for the values, the created + parameters or a refused load.""" + pm = ParameterManager(name="params") + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": { + "params._globals.qubit.octave_gain": {"value": 10, "unit": "dB"}, + "params.q01.octave_gain": { + "value": 12, + "unit": "dB", + "lock": { + "target": "params._globals.qubit.octave_gain", + "locked": True, + }, + }, + "params.q02.octave_gain": { + "value": 13, + "unit": "dB", + "lock": { + "target": "params._globals.qubit.octave_gain", + "locked": False, + }, + }, + }, + "types": { + "qubit": { + "parameters": { + "octave_gain": { + "default": 10, + "unit": "dB", + "target": "params._globals.qubit.octave_gain", + } + }, + "nested": {}, + } + }, + } + pm.fromParamDict(doc) + + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.qubit"), + (PM_LOCK_UPDATE, "params.q01.octave_gain"), + (PM_LOCK_UPDATE, "params.q02.octave_gain"), + ] + assert received[0].value.name == "qubit" + assert received[1].value == PMLockBluePrint( + target="params._globals.qubit.octave_gain", locked=True + ) + assert received[2].value == PMLockBluePrint( + target="params._globals.qubit.octave_gain", locked=False + ) + # the values and the created parameters emit nothing from the + # Parameter Manager (D22 keeps the reader's behaviour) + assert not any( + bp.action in (PARAMETER_CREATION, PARAMETER_DELETION) for bp in received + ) + + # a refused load emits nothing + received.clear() + refused = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.ghost", "locked": True}, + } + }, + "types": {}, + } + with pytest.raises(ValueError): + pm.fromParamDict(refused) + assert received == [] From f99f58055ec2dc2c36c36d7a0227ebccebf8afd5 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 10:36:05 -0500 Subject: [PATCH 064/107] 4.2: fix from review round 1: pin complete-Instance loads, invalid Type names, document-relative validation; rename the shim test; audit rows --- TEST_AUDIT.md | 3 +- src/instrumentserver/params.py | 2 +- test/pytest/test_pm_persistence.py | 150 +++++++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 8 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 3a2e54a..c2b5a26 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -40,7 +40,8 @@ States: | user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create a missing `_globals.*` parameter through the internal creation path (`_create_managed_parameter`, which the public `add_parameter` refusal of the `_globals` name bypasses), while every other missing parameter is created through the ordinary `add_parameter`; a profile file holding a `_globals.*` key creates the Globals parameter on load in both the legacy flat map and the version-2 document (create-on-load is intended for both, D18) | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)`; covered in 4.1, wording corrected in 4.2 | covered | `test_legacy_flat_file_creates_a_globals_parameter_on_load` and `test_globals_parameter_round_trips_through_the_internal_path` in `test/pytest/test_pm_persistence.py` | | user_guide/parameter_manager.md (future) | Locks — removing a parameter | `ParameterManager.remove_parameter` raises `KeyError()` when the parameter itself is missing but `ValueError` (from `_get_parent`) when an intermediate Parameter Group is missing, while `_get_param` raises `ValueError` for both; the 1.2/3.3 tests pin both types | Found during the plan 3.3 review (reviewer-qwen) | gap | Pre-existing since task 1.2; not changed per plan rule 6; if revisited, raise `ValueError` naming the full path and update the tests asserting `KeyError` | | user_guide/parameter_manager.md (future) | Profiles — loading a file | `ParameterManager.fromFile` accepts `deleteMissing` but never forwards it to `fromParamDict`, so the GUI's `fromFile(filePath=..., deleteMissing=False)` runs with the default `True` | Found during the plan 4.1 review (reviewer-glm, the coder) | gap | Pre-existing; not changed per plan rule 6; fix is a one-line forward plus a test | -| user_guide/parameter_manager.md (future) | Profiles — file validation | Both `schemas/parameters.json` and `schemas/parameter_manager_v2.json` use `patternProperties` without `additionalProperties: false`, so a parameter key that does not match `^(\w+)(\.\w+)*$` (e.g. with a space) passes validation and fails later in the loader | Found during the plan 4.1 review (plan-checker-qwen) | gap | Pre-existing in the legacy schema the plan protects; not changed per plan rule 6 | +| user_guide/parameter_manager.md (future) | Profiles — file validation | Both `schemas/parameters.json` and `schemas/parameter_manager_v2.json` use `patternProperties` without `additionalProperties: false`, so a parameter key that does not match `^(\w+)(\.\w+)*$` (e.g. with a space) passes validation and fails later in the loader, and a document listing both a parameter key and a dotted extension of it (`params.q01` and `params.q01.x`), or the bare key `params._globals`, passes validation and fails mid-load in the parameters step (a parameter cannot have child parameters) or silently shadows a submodule; inherited from the legacy reader | Found during the plan 4.1 review (plan-checker-qwen); extended during the plan 4.2 review | gap | Pre-existing in the legacy schema the plan protects; not changed per plan rule 6 | +| user_guide/parameter_manager.md (future) | Types — empty Type name | `add_type("")` succeeds (only `_globals` is refused, task 2.1), so a manager can hold an empty-named Type that `toFile` writes and the version-2 reader refuses; such a manager cannot round-trip | Found during the plan 4.2 review (test-reviewer-glm) | gap | Pre-existing since 2.1; not changed per plan rule 6; fix is an empty-name refusal in `add_type` plus a test | ## Manual checks diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 5fbbff8..a955cd4 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -2829,7 +2829,7 @@ def _load_v2_document(self, document: Dict[str, Any], deleteMissing: bool) -> No if deleteMissing: for pn in current_params: - if pn not in file_param_set and deleteMissing: + if pn not in file_param_set: self.remove_parameter(pn) # (c) the Types, written straight into the registry with no diff --git a/test/pytest/test_pm_persistence.py b/test/pytest/test_pm_persistence.py index cea09b1..5dc3832 100644 --- a/test/pytest/test_pm_persistence.py +++ b/test/pytest/test_pm_persistence.py @@ -347,11 +347,9 @@ def test_delete_missing_semantics_are_unchanged_on_a_version_two_load(tmp_path): assert pm.c() == 3 -def test_the_shim_ignores_the_lock_and_types_sections(tmp_path): - """The reader shim loads only the parameters map: the file may carry - ``lock`` entries and a ``types`` section, the parameters load with - their own values and units, and nothing about the Locks or Types is - pinned here (task 4.2 restores them).""" +def test_parameters_load_with_own_values_from_a_v2_file_with_locks_and_types(tmp_path): + """A version-2 file may carry ``lock`` entries and a ``types`` + section; the parameters still load with their own values and units.""" pm = make_populated_manager() pm.workingDirectory = tmp_path pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") @@ -586,6 +584,94 @@ def test_missing_targets_are_all_named_and_change_nothing(): assert received == [] +def test_invalid_type_names_in_the_document_are_refused_up_front(): + """A document whose Types include the reserved Globals name and the + empty name is refused with both offending names in one message, + before anything is loaded.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": {}, + "types": { + "_globals": {"parameters": {}, "nested": {}}, + "": {"parameters": {}, "nested": {}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert message.count("is not a valid Type name") == 2 + assert "the Globals submodule name is reserved" in message + assert "it is empty" in message + assert pm.list() == ["a"] + assert pm.list_types() == [] + assert received == [] + + +def test_target_validation_is_document_relative(): + """A Lock Target that exists in-session but is not a parameters key of + the document is refused: the validation reads the document, not the + live tree.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("b", "a") + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.b", "locked": True}, + } + }, + "types": {}, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc, deleteMissing=False) + + message = str(excinfo.value) + assert "params.b" in message + assert "not a parameter of the document" in message + # the live Lock and every value are unchanged, nothing was emitted + assert pm.list() == ["a", "b"] + assert pm.get_lock("b") == PMLockBluePrint(target="params.a", locked=True) + assert pm.parameter("b").own_value() == 2 + assert received == [] + + +def test_nested_type_validation_is_document_relative(): + """A Nested Type that exists in-session but is missing from the + document's ``types`` is refused: the validation reads the document.""" + pm = ParameterManager(name="params") + pm.add_type("qubit") + pm.add_type("readout") + pm.add_nested_type("qubit", "readout", "readout") + + doc = { + "version": 2, + "parameters": {}, + "types": { + "qubit": {"parameters": {}, "nested": {"readout": "readout"}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc, deleteMissing=False) + + assert "readout" in str(excinfo.value) + assert "not among the document's Types" in str(excinfo.value) + assert sorted(pm.list_types()) == ["qubit", "readout"] + assert pm.get_type("qubit").nested == {"readout": "readout"} + + def test_nested_types_missing_from_the_document_are_refused_up_front(): """A Nested Type that no Type of the document defines is refused before anything is loaded, with every offender named.""" @@ -763,6 +849,58 @@ def test_partial_instances_are_not_completed_and_get_no_type_lock(): ) +def test_a_complete_instance_gets_no_type_lock_on_load(): + """Even a submodule the document completes into a full Instance of a + Type whose entry carries a Type Lock gets no Lock on load (D20): the + Types load with no Instance side effects, and an Instance's Locks come + only from the document's ``lock`` entries.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": { + "params._globals.qubit.octave_gain": {"value": 10, "unit": "dB"}, + "params.q01.IF": {"value": 2e9, "unit": "Hz"}, + "params.q01.octave_gain": {"value": 12, "unit": "dB"}, + "params.q01.readout.power": {"value": -10, "unit": "dBm"}, + }, + "types": { + "readout": { + "parameters": { + "power": {"default": -10, "unit": "dBm", "target": None} + }, + "nested": {}, + }, + "qubit": { + "parameters": { + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": { + "default": 10, + "unit": "dB", + "target": "params._globals.qubit.octave_gain", + }, + }, + "nested": {"readout": "readout"}, + }, + }, + } + pm.fromParamDict(doc) + + # q01 carries the whole effective set with the declared units: it is + # an Instance, computed by matching, not written by the load + assert pm.instances_of("qubit") == ["q01"] + # no ``lock`` entries in the document and no Type Lock applied on load + assert pm.get_lock("q01.octave_gain") is None + assert pm.get_lock("q01.IF") is None + assert pm.list_locks() == {} + # the load created exactly the document's parameters, nothing more + assert set(pm.list()) == { + "_globals.qubit.octave_gain", + "q01.IF", + "q01.octave_gain", + "q01.readout.power", + } + + def test_a_listed_parameter_without_a_lock_entry_loses_its_lock(): pm = ParameterManager(name="params") pm.add_parameter("a", initial_value=1, unit="u") @@ -865,7 +1003,7 @@ def test_delete_missing_removes_absent_parameters_types_and_locks(): (PM_LOCK_UPDATE, "params.a"), (PM_LOCK_UPDATE, "params.c"), ] - assert received[0].value is not None + assert received[0].value == pm.get_type("kept") assert received[1].value is None assert received[2].value == PMLockBluePrint(target="params.b", locked=True) assert received[3].value is None From 83e9a12ef3d84680cb627a5589b74951a40bc0f0 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 10:43:40 -0500 Subject: [PATCH 065/107] 4.2: history --- HISTORY_parameter_manager_redesign.md | 44 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 0940a1f..94d7b2c 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -617,3 +617,47 @@ The Type API in `src/instrumentserver/params.py` now emits its own Broadcasts (D ### Process notes - Two permissions were rejected in round 0. test-reviewer-qwen and reviewer-qwen each tried an inline `uv run python - <<'EOF'` heredoc that could not be read in full. Each reran its check as a scanned probe script in `orchestration/4.1/round-0/` and deleted it afterwards. - The coder left a `mkdtemp` scratch folder under `orchestration/4.1/`, which the orchestrator removed. There were no stalls and no nudges. + +## 4.2 Reader — 2026-09-25 + +`ParameterManager.fromParamDict` now loads the whole version-2 profile document through the new `_load_v2_document`, and `fromFile` inherits this. The legacy flat map (no `version`) still loads parameters only and leaves Types and Locks alone. `_collect_v2_document_problems` validates the document before anything changes and joins every problem into one `ValueError`. After that the load runs in D20's order: it drops the Locks of every parameter the document lists, loads the parameters, writes the Types straight into `_types` with no Instance side effects, and puts each stored Lock back as stored. The Broadcasts go out once, after the whole load succeeds. `test/pytest/test_pm_persistence.py` grew from 21 to 41 tests, with a new checked-in fixture, `test/pytest/fixtures/parameter_manager-legacy.json`. + +### Commit by commit +- `b6ee025` The reader and 16 tests. The orchestrator's readings in the coder spec set these rules: + - Validation covers the schema (`validateParameterManagerV2`) and more. Every key must carry the `.` prefix. Type names must not be `_globals` or empty. Every Nested Type must be a Type of the document, with no Nested Type cycle and no path twice in an effective set. Every `lock.target` and non-null Type `target` must be a `parameters` key of the document, and each missing Target is named with the Followers or `.`s that refer to it. The document's Locks must hold no self-lock and no cycle. + - Load order: (a) the Locks on listed parameters go first, so setting a stored own value on a locked Follower cannot raise (D6; this was the 4.1 loose end); (b) the parameters, with the old `deleteMissing` semantics, and Globals through `_create_managed_parameter`; (c) the Types, and with `deleteMissing=True` every Type the document does not define is removed; (d) each Lock is set directly with its stored Target and `locked` state, after `_check_lock_allowed`. + - Broadcasts: one `pm-type-update` per Type written, one with `None` per Type removed, then one `pm-lock-update` per Lock that ended different from before, in tree order. Nothing is emitted for values, and no `parameter-creation`/`parameter-deletion` is re-emitted. The docstring says a GUI must refresh its structure after a profile load (for 5.1). + - `fromFile` still does not forward `deleteMissing` (rule 6, TEST_AUDIT row "Profiles — loading a file"). + + To validate the document's Types without touching the registry, `_require_type`, `_expand_effective` and `_collect_effective` gained an optional `types` argument. The reader passes them a candidate registry built from the document. During the load the coder detaches `_broadcast_sinks` and restores them in a `finally`, so `remove_parameter`'s own Broadcasts do not mix into the load's single report. The commit also corrects the wording of the TEST_AUDIT row "Profiles — loading Globals parameters": only `_globals.*` parameters take the internal path (reading 6). The coder reported one possible edge: with `deleteMissing=False`, step (d)'s `_check_lock_allowed` might raise mid-load. All six reviewers judged it unreachable, because validation requires every Target to be a key of the document and step (a) has already cleared those parameters' Locks. reviewer-glm and test-reviewer-glm each confirmed this with a probe. The tests cover: + - the four the plan names: `test_legacy_fixture_file_loads_values_and_units`, `test_version_two_document_round_trips_exactly` (document equality, pull-on-get, own values, both Lock states), `test_missing_targets_are_all_named_and_change_nothing` and `test_partial_instances_are_not_completed_and_get_no_type_lock` + - up-front refusals: a Nested Type missing from the document, a Nested Type cycle, a duplicated effective path, a Lock cycle, a self-lock, and a key of another instrument + - `test_a_listed_parameter_without_a_lock_entry_loses_its_lock` and `test_a_locked_follower_loads_its_own_value_and_the_files_lock_state` + - `deleteMissing` both ways, Broadcast order and content for one load, and `test_legacy_load_leaves_types_and_locks_untouched` + + Orchestrator run: ruff clean, 50 in the two named files, 455 in the full suite. +- `f99f580` Fix from round 0, six items: + - `test_a_complete_instance_gets_no_type_lock_on_load`: the document completes `q01` into a `qubit` Instance whose `octave_gain` entry has a Type Lock, and carries no `lock` entries. After the load, `instances_of("qubit") == ["q01"]`, `list_locks() == {}`, and `list()` holds exactly the document's four keys. test-reviewer-glm caught the gap (should-fix). The partial-Instance test could not catch Type Locks being applied on load, because its `q01` never matches the Type. + - `test_invalid_type_names_in_the_document_are_refused_up_front`: `_globals` and `""` are both named in one message, and nothing changes or is emitted. Both test reviewers caught it (should-fix): the check existed but no test ran it. + - `test_target_validation_is_document_relative` and `test_nested_type_validation_is_document_relative`: a Target or Nested Type that exists in-session but not in the document is refused. test-reviewer-glm caught it (should-fix). Every earlier refusal test used names that existed nowhere, so a regression to "accept it if it exists in-session" would have gone unnoticed. This is also the coder's reported edge. + - The 4.1 test `test_the_shim_ignores_the_lock_and_types_sections` became `test_parameters_load_with_own_values_from_a_v2_file_with_locks_and_types`, with its docstring reworded and its assertions unchanged. test-reviewer-glm raised it as should-fix, and plan-checker-glm, plan-checker-qwen and test-reviewer-qwen as nits. The `pm-type-update` payload check in `test_delete_missing_removes_absent_parameters_types_and_locks` went from `is not None` to `== pm.get_type("kept")` (test-reviewer-qwen, nit, folded in). + - The redundant `and deleteMissing` inside the `if deleteMissing:` loop is gone (reviewer-glm, reviewer-qwen, nits). It was sent because both reviewer models raised it and it is one line. + - `TEST_AUDIT.md`: the "Profiles — file validation" row now also mentions documents holding both `params.q01` and `params.q01.x`, or a bare `params._globals`. These pass validation and then fail mid-load or shadow a submodule, a hole inherited from the legacy reader (reviewer-glm). A new `gap` row, "Types — empty Type name": `add_type("")` succeeds, so such a manager is written by `toFile` and then refused by the reader (test-reviewer-glm, nit). + + All six approved in re-review, and every raiser confirmed their item fixed. Orchestrator run: ruff clean, 54 in the two named files, 459 in the full suite. + +### Dropped findings +- The `assert target_param is not None` in step (d) would be skipped under `python -O` (reviewer-qwen, nit) → not sent. The value cannot be `None` after validation, and the codebase already uses asserts for such states. +- The loop variable `spec` for a document Type could be `type_spec` (plan-checker-qwen, nit) → not sent. +- Round 1: the extended "Profiles — file validation" row puts the bare `params._globals` key under the wrong failure. It actually fails on the reserved Globals name in `add_parameter`, and the shadowing happens only when the dotted key comes first (reviewer-qwen, nit) → not sent. The wording is to be corrected when the row is next touched. + +### Questions to Marcos +- The orchestrator flagged its coder-spec readings for Marcos: the validation list, the load order with Locks cleared first, `deleteMissing` also removing Types, Broadcasts after the load with no creation/deletion re-emission, and `fromFile` left not forwarding `deleteMissing`. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- For 5.1: a profile load emits no `parameter-creation`/`parameter-deletion`, so the GUI must refresh its structure after a load (stated in the `fromParamDict` docstring). +- `TEST_AUDIT.md` gaps still open: "Profiles — loading a file" (`fromFile` drops `deleteMissing`), "Profiles — file validation" (now also key collisions), and "Types — empty Type name". +- The decisions.md note on the coder's mid-load edge overstates it; the reviewers showed it cannot be reached. No test loads a changed unit onto an existing parameter, which was already the case before this task (test-reviewer-glm). + +### Process notes +- Seven permissions were rejected in round 0. The coder asked for `/tmp`. reviewer-qwen asked for opencode's temp directory. plan-checker-qwen mistyped the repo path as `/Users/marcof2/...`. plan-checker-glm, test-reviewer-glm and reviewer-glm each tried an inline `uv run python - <<'EOF'` heredoc, and test-reviewer-qwen an inline `python -c`, none of which could be read in full. Each reran its check as a scanned probe under `orchestration/4.2/round-0/`. All probes and logs were deleted afterwards. There were no stalls and no nudges. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index fa17f60..5e26fca 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -534,7 +534,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session `schemas/parameter_manager_v2.json`. Update the flat-shape assertions in `test_param_manager.py` to `["parameters"][...]`. Tests: `test_pm_persistence.py` writer cases; `test_param_manager.py` green. -- [ ] **4.2 Reader.** `fromParamDict`/`fromFile`: detect legacy (no `version`) vs 2; validate +- [x] **4.2 Reader.** `fromParamDict`/`fromFile`: detect legacy (no `version`) vs 2; validate the whole document first (every `lock.target` and every Type `target` must be a key in `parameters`; otherwise `ValueError` listing **all** missing Targets, state untouched); then load parameters (existing `deleteMissing` semantics), then Types without Instance From 925967c184335c48984ca0bb0ea61f556ef63ed7 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 10:51:29 -0500 Subject: [PATCH 066/107] 4.3: switch_to_profile clears Types and Locks via _clear_all (save, clear, load) with profile-switching tests --- src/instrumentserver/params.py | 38 +++- test/pytest/test_pm_persistence.py | 307 ++++++++++++++++++++++++++++- 2 files changed, 340 insertions(+), 5 deletions(-) diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index a955cd4..37c4137 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -2458,6 +2458,34 @@ def remove_all_parameters(self) -> None: self.remove_parameter(param, cleanup=False) self.remove_empty_submodules() + def _clear_all(self) -> None: + """Remove every Type from the Type registry, then all parameters + with their Locks: the clear step of :meth:`switch_to_profile` + (D20). + + The Types go first, straight out of the registry with no + parameter side effects (D13) — one ``pm-type-update`` Broadcast + with a ``None`` payload per removed Type, in registry order + (D22). Removing them first means the parameter removals below + find no Type Lock to clear, so no further ``pm-type-update`` is + emitted. :meth:`remove_all_parameters` then removes every + parameter — the Globals parameters included — dropping their + Locks (one ``pm-lock-update`` Broadcast with a ``None`` payload + per dropped Lock, D10) and the Parameter Groups left empty. + """ + removed_types = list(self._types) + for type_name in removed_types: + del self._types[type_name] + for type_name in removed_types: + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{type_name}", + action=PM_TYPE_UPDATE, + value=None, + ) + ) + self.remove_all_parameters() + def fromFile( self, filePath: str | None = None, @@ -3038,13 +3066,19 @@ def list_profiles(self) -> List[str]: def switch_to_profile(self, profile: str) -> None: """ - Switches the server to the passed profile. + Switches to the passed profile (D20): the current profile is + saved first (the version-2 profile document :meth:`toFile` + writes), then every parameter, Type and Lock is cleared + (:meth:`_clear_all`), then the new profile is loaded + (:meth:`fromFile`, a legacy flat map or a version-2 document). + Raises ``ValueError`` — saving, clearing and loading nothing — + when the profile does not exist. """ if not self.does_profile_exist(self.profiles, profile): raise ValueError(f"Profile {profile} does not exist") self.toFile(str(self.workingDirectory), self.selectedProfile) - self.remove_all_parameters() + self._clear_all() self.fromFile( str(self.workingDirectory.joinpath(self.fullProfileName(profile))) ) diff --git a/test/pytest/test_pm_persistence.py b/test/pytest/test_pm_persistence.py index 5dc3832..f0e18bc 100644 --- a/test/pytest/test_pm_persistence.py +++ b/test/pytest/test_pm_persistence.py @@ -1,5 +1,5 @@ """Tests for the Parameter Manager's version-2 persistence (plan tasks -4.1 and 4.2). +4.1, 4.2 and 4.3). The writer part checks the document :meth:`ParameterManager.toParamDict` produces (plan decision D19): ``version`` 2, ``parameters`` keyed by full @@ -11,8 +11,18 @@ validated as a whole — refused with every problem named, state untouched — and then loads in D20's order (the Locks of the listed parameters, the parameters, the Types without Instance side effects, the Locks), with the -load's Broadcasts emitted once after it succeeded. All tests are unit tests -on a local Parameter Manager with no Server involved. +load's Broadcasts emitted once after it succeeded. The profile part checks +:meth:`ParameterManager.switch_to_profile` (D20): the leaving profile is +saved as a version-2 document, then parameters, Types and Locks are +cleared (:meth:`ParameterManager._clear_all`, whose Broadcasts are the +``pm-type-update`` ``None`` payloads of the removed Types first, then +whatever the parameter removal emits), then the new profile loads — a +version-2 document or a legacy flat map — so switching between profiles +round-trips the Types, the Locks and the Globals parameters, switching to +a profile without any leaves none behind, an unknown profile raises and +saves nothing, and ``refresh_profiles``/``list_profiles`` are unchanged. +All tests are unit tests on a local Parameter Manager with no Server +involved. """ import json @@ -1108,3 +1118,294 @@ def test_one_load_emits_type_updates_then_lock_updates_and_nothing_for_values(): with pytest.raises(ValueError): pm.fromParamDict(refused) assert received == [] + + +# --------------------------------------------------------------------------- +# Profiles (4.3): switch_to_profile = save → clear → load (D20) +# --------------------------------------------------------------------------- + + +EMPTY_DOC = { + "version": 2, + "parameters": {"params.spare": {"value": 1, "unit": "V"}}, + "types": {}, +} + + +def make_profile_switch_manager(tmp_path, monkeypatch, name="params"): + """The first profile of the switching tests: a Parameter Manager + holding Types (one nesting another), an Instance of the outer Type, a + Type Lock with its default Globals Target (given its own value), an + explicit-Target Lock and an unlocked Lock — saved as profile + ``typed`` beside the profile ``empty``, which holds one parameter and + no Types and no Locks. Both files are listed as profiles.""" + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name=name) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_instance("qubit", "q01") + # the Type Lock with the default Globals Target: it creates the + # Globals parameter on demand and locks the Instance's parameter + pm.lock_type_parameter("qubit", "octave_gain") + pm.set("_globals.qubit.octave_gain", 33) + # an ordinary Lock with an explicit Target, and an unlocked Lock + pm.add_parameter("q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.add_parameter("monitor.IF", initial_value=0, unit="Hz") + pm.lock("monitor.IF", "q01Data.IF") + pm.unlock("monitor.IF") + pm.toFile(tmp_path, "typed") + (tmp_path / "parameter_manager-empty.json").write_text(json.dumps(EMPTY_DOC)) + pm.refresh_profiles() + return pm + + +def test_switching_to_a_profile_without_types_or_locks_clears_them( + tmp_path, monkeypatch +): + """Switching from a profile holding Types, a Type Lock with its + Globals Target, Locks in both states and a Globals parameter to a + profile without any leaves no Type, no Lock and no Globals submodule + behind: only the new profile's parameters are there (D20).""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + + pm.switch_to_profile("empty") + + assert pm.list_types() == [] + assert pm.list_locks() == {} + assert "_globals" not in pm.submodules + assert pm.list() == ["spare"] + assert pm.get("spare") == 1 + assert pm.parameter("spare").unit == "V" + assert pm.selectedProfile == "parameter_manager-empty.json" + + +def test_switching_back_restores_types_locks_and_the_globals_parameter( + tmp_path, monkeypatch +): + """Switching to a profile without Types and back restores every Type + (``get_type`` equal), every Lock in its stored state, and the Globals + Target parameter with its own value: the round trip through the + profiles.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + before_types = {t: pm.get_type(t) for t in pm.list_types()} + before_locks = pm.list_locks() + + pm.switch_to_profile("empty") + assert pm.list_types() == [] + assert pm.list_locks() == {} + + pm.switch_to_profile("typed") + + assert sorted(pm.list_types()) == ["qubit", "readout"] + for type_name, blueprint in before_types.items(): + assert pm.get_type(type_name) == blueprint + assert pm.list_locks() == before_locks + # the Globals Target parameter comes back with its own value ... + assert pm.get("_globals.qubit.octave_gain") == 33 + assert pm.parameter("_globals.qubit.octave_gain").unit == "dB" + # ... and the restored Locks pull again: q01.octave_gain is locked to + # the Globals Target, q01.IF to its explicit Target, and monitor.IF + # stays unlocked and answers with its own value (D5) + assert pm.get("q01.octave_gain") == 33 + assert pm.parameter("q01.octave_gain").own_value() == 10 + assert pm.get("q01.IF") == 42e6 + assert pm.get("monitor.IF") == 0 + assert pm.instances_of("qubit") == ["q01"] + assert set(pm.list()) == { + "q01.IF", + "q01.octave_gain", + "q01.readout.power", + "monitor.IF", + "q01Data.IF", + "_globals.qubit.octave_gain", + } + assert pm.selectedProfile == "parameter_manager-typed.json" + + +def test_the_switch_saves_the_leaving_profile_as_a_version_two_document( + tmp_path, monkeypatch +): + """The save step of a switch writes the leaving profile as the + version-2 document (D19): the Types with the full-form Type Lock + Target, the Followers' ``lock`` entries in both states and the + Globals parameter with its own value. A change made after the + helper's own save can only have been recorded by the switch's + save.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + pm.set("_globals.qubit.octave_gain", 34) + pm.refresh_profiles() + + pm.switch_to_profile("empty") + + with open(tmp_path / "parameter_manager-typed.json") as f: + doc = json.load(f) + assert doc["version"] == 2 + assert set(doc["types"]) == {"qubit", "readout"} + assert doc["types"]["qubit"]["nested"] == {"readout": "readout"} + assert doc["types"]["qubit"]["parameters"]["octave_gain"] == { + "default": 10, + "unit": "dB", + "target": "params._globals.qubit.octave_gain", + } + assert doc["types"]["qubit"]["parameters"]["IF"] == { + "default": None, + "unit": "Hz", + "target": None, + } + assert doc["parameters"]["params._globals.qubit.octave_gain"] == { + "value": 34, + "unit": "dB", + } + assert doc["parameters"]["params.q01.octave_gain"]["lock"] == { + "target": "params._globals.qubit.octave_gain", + "locked": True, + } + assert doc["parameters"]["params.q01.IF"]["lock"] == { + "target": "params.q01Data.IF", + "locked": True, + } + assert doc["parameters"]["params.monitor.IF"]["lock"] == { + "target": "params.q01Data.IF", + "locked": False, + } + + +def test_switching_to_a_legacy_flat_profile_leaves_no_type_or_lock( + tmp_path, monkeypatch +): + """A legacy flat profile file (no ``version`` key) loads as + parameters only; switching to it works, and no Type and no Lock of + the previous profile is left — the clear step ran before the load.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + legacy = { + "params.legacy_a": {"value": 5, "unit": "V"}, + "params.legacy_b.child": {"value": 6, "unit": "A"}, + } + (tmp_path / "parameter_manager-legacy_flat.json").write_text(json.dumps(legacy)) + pm.refresh_profiles() + + pm.switch_to_profile("legacy_flat") + + assert pm.list_types() == [] + assert pm.list_locks() == {} + assert set(pm.list()) == {"legacy_a", "legacy_b.child"} + assert pm.get("legacy_a") == 5 + assert pm.parameter("legacy_a").unit == "V" + assert pm.get("legacy_b.child") == 6 + assert "_globals" not in pm.submodules + assert pm.selectedProfile == "parameter_manager-legacy_flat.json" + + +def test_clear_all_emits_type_updates_then_lock_updates_and_nothing_else( + tmp_path, monkeypatch +): + """``_clear_all`` emits one ``pm-type-update`` with a ``None`` payload + per removed Type, in registry order, then whatever + ``remove_all_parameters`` emits — the ``pm-lock-update`` ``None`` of a + Lock dropped with its Target — and nothing else. No Type Lock clearing + is reported, because the Types are already out of the registry when + the parameters go.""" + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name="params") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_instance("qubit", "q01") + pm.lock_type_parameter("qubit", "IF") # Globals Target _globals.qubit.IF + # a Lock whose Follower is removed after its Target in tree order, so + # its removal is reported with a pm-lock-update + pm.add_parameter("q01Data.target", initial_value=1, unit="V") + pm.add_parameter("follower", initial_value=0, unit="V") + pm.lock("follower", "q01Data.target") + + received = [] + pm.add_broadcast_sink(received.append) + received.clear() + + pm._clear_all() + + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.qubit"), + (PM_TYPE_UPDATE, "params.readout"), + (PM_LOCK_UPDATE, "params.follower"), + ] + assert all(bp.value is None for bp in received) + # everything is gone, the Globals submodule included + assert pm.list_types() == [] + assert pm.list_locks() == {} + assert pm.list() == [] + assert "_globals" not in pm.submodules + + +def test_switch_to_an_unknown_profile_raises_and_saves_nothing( + tmp_path, monkeypatch +): + """An unknown profile raises ``ValueError`` before anything happens: + the current profile file keeps its contents and modification time, + the state of the Parameter Manager is untouched, and no file is + created.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + pm.refresh_profiles() + typed_path = tmp_path / "parameter_manager-typed.json" + before_mtime = typed_path.stat().st_mtime_ns + before_text = typed_path.read_text() + before_state = (pm.list(), pm.list_locks(), pm.list_types()) + + with pytest.raises(ValueError, match="Profile no_such does not exist"): + pm.switch_to_profile("no_such") + + assert typed_path.stat().st_mtime_ns == before_mtime + assert typed_path.read_text() == before_text + assert (pm.list(), pm.list_locks(), pm.list_types()) == before_state + assert pm.selectedProfile == "parameter_manager-typed.json" + assert sorted(path.name for path in tmp_path.iterdir()) == [ + "parameter_manager-empty.json", + "parameter_manager-typed.json", + ] + + +def test_refresh_and_list_profiles_are_the_same_around_a_switch( + tmp_path, monkeypatch +): + """``refresh_profiles``/``list_profiles`` are unchanged by a switch: + they list the same profile files before and after — plus the current + profile's file when the switch's save creates it.""" + # both profile files exist before the switch + pm = make_profile_switch_manager(tmp_path, monkeypatch) + before = sorted(pm.refresh_profiles()) + assert sorted(pm.list_profiles()) == before + + pm.switch_to_profile("empty") + + assert sorted(pm.refresh_profiles()) == before + assert sorted(pm.list_profiles()) == before + + # the current profile's file does not exist yet: the switch's save + # creates it, and the refreshed list grows by exactly that file + (tmp_path / "parameter_manager-empty2.json").write_text( + json.dumps( + { + "version": 2, + "parameters": {"params2.spare2": {"value": 2, "unit": "W"}}, + "types": {}, + } + ) + ) + pm2 = ParameterManager(name="params2") + pm2.add_parameter("x", initial_value=1, unit="u") + before2 = sorted(pm2.refresh_profiles()) + assert "parameter_manager-params2.json" not in before2 + + pm2.switch_to_profile("empty2") + + after2 = sorted(pm2.refresh_profiles()) + assert after2 == sorted(before2 + ["parameter_manager-params2.json"]) + assert sorted(pm2.list_profiles()) == after2 + assert pm2.get("spare2") == 2 From dc15389958126d3882a44d0eba469e4f2fd2cf42 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 11:00:23 -0500 Subject: [PATCH 067/107] 4.3: history --- HISTORY_parameter_manager_redesign.md | 37 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 94d7b2c..48821a4 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -661,3 +661,40 @@ The Type API in `src/instrumentserver/params.py` now emits its own Broadcasts (D ### Process notes - Seven permissions were rejected in round 0. The coder asked for `/tmp`. reviewer-qwen asked for opencode's temp directory. plan-checker-qwen mistyped the repo path as `/Users/marcof2/...`. plan-checker-glm, test-reviewer-glm and reviewer-glm each tried an inline `uv run python - <<'EOF'` heredoc, and test-reviewer-qwen an inline `python -c`, none of which could be read in full. Each reran its check as a scanned probe under `orchestration/4.2/round-0/`. All probes and logs were deleted afterwards. There were no stalls and no nudges. + +## 4.3 Profiles — 2026-09-25 + +`ParameterManager.switch_to_profile` now follows D20: it saves the current profile as the version-2 document `toFile` writes, clears parameters, Types and Locks with the new private `_clear_all()`, and then loads the new profile through `fromFile` (legacy flat map or version 2). `_clear_all` deletes every Type straight out of `_types` and emits one `pm-type-update` with `None` per Type, in registry order. It then calls `remove_all_parameters`, which is unchanged and emits its usual `pm-lock-update` `None`s. `test/pytest/test_pm_persistence.py` grew from 41 to 48 tests. The task finished in one commit with no fix round. + +### Commit by commit +- `925967c` `_clear_all`, the new clear step in `switch_to_profile` and seven tests. The orchestrator's readings in the coder spec set these rules: + - take the plan's second option: `remove_all_parameters` keeps its signature and behaviour (rule 7), so a direct call still leaves the Type definitions in place, as 3.3 pinned + - Types go before parameters, so `remove_parameter` finds no Type Lock to clear and emits no extra `pm-type-update` + - `switch_to_profile` validates first (an unknown profile raises `ValueError` and nothing is saved), then save → `_clear_all` → load, then `selectedProfile` as before + - `refresh_profiles`/`list_profiles` stay unchanged, and a test asserts it + + Deleting from `_types` directly, rather than calling `remove_type` for each Type, is needed because `remove_type` refuses a Type that another Type nests (reviewer-glm and plan-checker-qwen both checked this). The coder's caller check found one production caller of `switch_to_profile`, the GUI's `loadProfile` at `gui/instruments.py:851`, which keeps working unchanged. The tests share a helper, `make_profile_switch_manager`, which builds a profile `typed` holding two Types (one nesting the other), an Instance, a Type Lock whose Globals Target has its own value, an explicit-Target Lock and an unlocked Lock. It saves that profile next to a parameter-only profile `empty`. The tests: + - `test_switching_to_a_profile_without_types_or_locks_clears_them`, the plan's named test: no Type, no Lock, no `_globals` submodule, only the new profile's parameter + - `test_switching_back_restores_types_locks_and_the_globals_parameter`: `get_type` equal, `list_locks()` equal, the Globals value back, and the Followers pulling again + - `test_the_switch_saves_the_leaving_profile_as_a_version_two_document`: it changes a value after the helper's own save, so only the switch's save can have written it + - `test_switching_to_a_legacy_flat_profile_leaves_no_type_or_lock` + - `test_clear_all_emits_type_updates_then_lock_updates_and_nothing_else`: two `pm-type-update` `None`s in registry order, then one `pm-lock-update` `None`, and nothing else + - `test_switch_to_an_unknown_profile_raises_and_saves_nothing`: file contents and `st_mtime_ns` unchanged, and no new file + - `test_refresh_and_list_profiles_are_the_same_around_a_switch`, including a second manager whose own profile file is created by the switch's save + + The existing switching tests in `test_param_manager.py` and the `remove_all_parameters` tests in `test_pm_types.py` and `test_pm_locks.py` pass unchanged. All six reviewers approved in round 0 with no must-fix or should-fix findings. Orchestrator run: ruff clean, 61 in the two named files, 466 in the full suite. + +### Dropped findings +None of these were sent, since there was no fix round: +- If the load step fails (corrupt JSON, or a version-2 document the 4.2 validation refuses), the switch has already saved and cleared, so the manager is left empty (reviewer-glm, nit; test-reviewer-qwen, nit, asking for a test that pins it). This follows from D20's order, and before 4.3 the same path already cleared the parameters. 4.3 only adds the Types and Locks to what gets cleared. reviewer-glm suggested that a later task could read and validate the target document before saving and clearing. The orchestrator flagged this for Marcos. +- The `_clear_all` Broadcast test depends on the order `_to_tree` removes parameters in: Parameter Groups first, then root parameters. `follower` goes after its Target, so its dropped Lock is announced, while `q01.IF` goes before its Globals Target, so its Lock dies unannounced. Both test reviewers raised this as a nit, and test-reviewer-glm suggested a comment line for the `q01.IF` half. A change in removal order would make the test fail loudly, not pass silently. +- `_clear_all` builds the `pm-type-update` `None` Broadcast inline, the third copy next to `remove_type` and `_broadcast_type_update` (reviewer-glm, nit). `_broadcast_type_update` can't be reused because it calls `get_type` on the Type that was just removed. +- The tests move into `tmp_path` with `monkeypatch.chdir` instead of setting `workingDirectory` as reading 5 worded it (plan-checker-glm, nit). The result is the same. + +### Questions to Marcos +- The orchestrator flagged its coder-spec readings for Marcos (a private `_clear_all` with `remove_all_parameters` untouched, and validate → save → clear → load), along with the emptied manager after a failed load. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- `does_profile_exist` matches by substring (reviewer-qwen, pre-existing). A name that is a substring of an existing profile file passes the check, and the switch then saves, clears and loads a missing file, which `fromFile` only warns about, so the manager ends up empty. `decisions.md` sends it to TEST_AUDIT at 6.3. No `TEST_AUDIT.md` row exists yet. +- test-reviewer-glm: the switch-level tests alone can't show that `_clear_all` did the clearing, because the 4.2 reader with `deleteMissing=True` also removes Types the document doesn't define. `test_clear_all_emits_type_updates_then_lock_updates_and_nothing_else` pins the clear step directly. +- For Phase 5: the GUI's `loadProfile` now clears Types and Locks on a switch too. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 5e26fca..fdbb987 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -541,7 +541,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session side effects, then Locks (locked/unlocked as stored). Tests: `test_pm_persistence.py` — legacy fixture file loads; round-trip equality; missing Targets error lists every one and leaves the previous state intact; partial Instances are not "completed" on load. -- [ ] **4.3 Profiles.** `remove_all_parameters` also clears Types and Locks (or add +- [x] **4.3 Profiles.** `remove_all_parameters` also clears Types and Locks (or add `_clear_all()` used by `switch_to_profile`); `switch_to_profile` = save → clear → load. `refresh_profiles`/`list_profiles` unchanged. Tests: `test_pm_persistence.py` — switching between a profile with Types and one without leaves no Type or Lock behind; existing From 9928d03ee6778fe352beee83e2edd44c77f451a1 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 11:14:22 -0500 Subject: [PATCH 068/107] 5.1: client-side PMState and pm-lock-update/pm-type-update routing in the Parameter Manager GUI --- src/instrumentserver/gui/instruments.py | 107 ++++++- test/pytest/test_pm_gui.py | 372 ++++++++++++++++++++++++ 2 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 test/pytest/test_pm_gui.py diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 682f912..2d89f8b 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -12,7 +12,11 @@ PARAMETER_CREATION, PARAMETER_DELETION, PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, ) from ..client import ProxyInstrument, SubClient from ..helpers import nestedAttributeFromString @@ -414,6 +418,18 @@ class ModelParameters(InstrumentModelBase): #: name, second object is its new value itemNewValue = QtCore.Signal(object, object) + #: Signal(str, object) -- + #: Emitted on a ``pm-lock-update`` Broadcast: the Follower's path relative + #: to the instrument, and its :class:`PMLockBluePrint` (``None`` when its + #: Lock was removed). No model item is touched for this action. + lockChanged = QtCore.Signal(str, object) + + #: Signal(str, object) -- + #: Emitted on a ``pm-type-update`` Broadcast: the Type's name (the part + #: after the instrument name), and its :class:`PMTypeBluePrint` (``None`` + #: when the Type was removed). No model item is touched for this action. + typeChanged = QtCore.Signal(str, object) + def __init__(self, *args: Any, **kwargs: Any) -> None: # make sure we pass the server ip and port properly to the subscriber when the values are not defaults. subClientArgs = { @@ -489,6 +505,15 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: # The model can't actually modify the widget since it knows nothing about the view itself. self.itemNewValue.emit(item[0].name, bp.value) + elif bp.action == PM_LOCK_UPDATE: + # Locks and Types claim no model item of their own: the Lock + # column and the Type tints are separate tasks. The Parameter + # Manager GUI records the change in its PMState (D22). + self.lockChanged.emit(fullName, bp.value) + + elif bp.action == PM_TYPE_UPDATE: + self.typeChanged.emit(fullName, bp.value) + def insertItemTo( self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem ) -> None: @@ -711,7 +736,13 @@ def __init__( @QtCore.Slot(object, object) def onItemNewValue(self, itemName: str, value: Any) -> None: widget = self.delegate.parameters[itemName] - widget.paramWidget.setValue(value) + try: + # use the abstract set method defined in parameter widget so it works for different types of widgets + widget._setMethod(value) + except RuntimeError: + logger.debug( + f"Could not set value for {itemName} to {value}. Object is not being shown right now." + ) class ProfilesManager(QtWidgets.QComboBox): @@ -750,6 +781,67 @@ def onCurrentIndexChanged(self, index: int) -> None: self.indexChanged.emit() +class PMState: + """Client-side cache of a Parameter Manager's Types and Locks. + + The Parameter Manager GUI owns one instance (``ParameterManagerGui.state``) + so its widgets can react to Types and Locks without querying the Server + again. It starts empty and is filled from the Parameter Manager — a Proxy + Instrument or a local one — with :meth:`refresh`; the ``pm-lock-update`` + and ``pm-type-update`` Broadcasts then keep single entries current through + :meth:`apply_lock` and :meth:`apply_type` (D22). + + ``types`` maps each Type's name to its :class:`PMTypeBluePrint`; ``locks`` + maps each Follower's path relative to the Parameter Manager — the form + ``list_locks()`` returns — to its :class:`PMLockBluePrint`. + """ + + def __init__(self) -> None: + self.types: Dict[str, PMTypeBluePrint] = {} + self.locks: Dict[str, PMLockBluePrint] = {} + + def refresh(self, instrument: Any) -> None: + """Re-read every Type and Lock from the Parameter Manager. + + Works with a Proxy Instrument and with a local Parameter Manager: + both expose ``list_types``, ``get_type`` and ``list_locks``. + + :param instrument: the Parameter Manager whose Types and Locks to + read. + """ + self.types = { + type_name: instrument.get_type(type_name) + for type_name in instrument.list_types() + } + self.locks = dict(instrument.list_locks()) + + def apply_lock(self, path: str, lock: Optional[PMLockBluePrint]) -> None: + """Record the change a ``pm-lock-update`` Broadcast reports about + the Follower at ``path``. + + :param path: the Follower's path relative to the Parameter Manager. + :param lock: the Follower's :class:`PMLockBluePrint`, or ``None`` + when its Lock was removed (the entry is dropped then). + """ + if lock is None: + self.locks.pop(path, None) + else: + self.locks[path] = lock + + def apply_type(self, name: str, type_blueprint: Optional[PMTypeBluePrint]) -> None: + """Record the change a ``pm-type-update`` Broadcast reports about + the Type ``name``. + + :param name: the Type's name. + :param type_blueprint: the Type's :class:`PMTypeBluePrint`, or + ``None`` when the Type was removed (the entry is dropped then). + """ + if type_blueprint is None: + self.types.pop(name, None) + else: + self.types[name] = type_blueprint + + class ParameterManagerGui(InstrumentParameters): #: Signal(str) -- #: emitted when there's an error during parameter creation. @@ -772,6 +864,10 @@ def __init__( callSignals=False, **kwargs, ) + # The client-side cache of the Parameter Manager's Types and Locks. + # Created before connectSignals, which wires the model's Broadcast + # routing into it. + self.state = PMState() self.profileManager = ProfilesManager(parent=self) self.addParam = AddParameterWidget(parent=self) layout = self.layout() @@ -780,6 +876,7 @@ def __init__( layout.addWidget(self.addParam) self.connectSignals() self.loadProfile() + self.state.refresh(self.instrument) def connectSignals(self) -> None: super().connectSignals() @@ -788,6 +885,8 @@ def connectSignals(self) -> None: self.parameterCreationError.connect(self.addParam.setError) self.parameterCreated.connect(self.addParam.clear) self.profileManager.indexChanged.connect(self.loadProfile) + self.model.lockChanged.connect(self.state.apply_lock) + self.model.typeChanged.connect(self.state.apply_type) self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) self.shortcutManager.register("clear_add", self.addParam.clear, self) self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) @@ -825,6 +924,7 @@ def refreshAll(self) -> None: super().refreshAll() self.instrument.refresh_profiles() self.profileManager.refresh() + self.state.refresh(self.instrument) def removeParameter(self, fullName: str) -> None: if self.instrument.has_param(fullName): @@ -851,12 +951,17 @@ def loadProfile(self) -> None: self.instrument.switch_to_profile(profileName) super().refreshAll() self.instrument.refresh_profiles() + # a profile load emits no parameter-creation/parameter-deletion + # Broadcasts for the parameters it (re)creates, so the state of the + # Types and Locks must be re-read from the Parameter Manager + self.state.refresh(self.instrument) @QtCore.Slot() def loadFromFile(self, loadFile: Optional[str] = None) -> None: try: self.instrument.fromFile(filePath=loadFile, deleteMissing=False) self.refreshAll() + self.state.refresh(self.instrument) except Exception as e: logger.info(f"Loading failed. {type(e)}: {e.args}") diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py new file mode 100644 index 0000000..57b1d47 --- /dev/null +++ b/test/pytest/test_pm_gui.py @@ -0,0 +1,372 @@ +"""Client-side state and Broadcast handling of the Parameter Manager GUI +(plan task 5.1). + +The GUI keeps the Parameter Manager's Types and Locks in a ``PMState`` +(``ParameterManagerGui.state``), filled from the Parameter Manager on +construction and on every refresh, and kept current by the +``pm-lock-update`` and ``pm-type-update`` Broadcasts the model routes to it. +A second Client's changes must reach that state and the tree's value +widgets without any polling, so every cross-client assertion waits with +``qtbot.waitUntil``. + +Two shapes of the live path are deliberately avoided in these tests, both +pre-existing and outside this task's scope: + +- a parameter another Client creates while the GUI is open makes the + model's creation branch resolve it on the GUI's (stale) Proxy Instrument + blueprint, which raises; +- ``lock_type_parameter`` without an explicit Target creates the Globals + parameter ``_globals..``, whose ``parameter-creation`` + Broadcast hits the same branch. The Type Lock test therefore declares an + explicit Target. +""" + +import os + +import pytest + +from instrumentserver.blueprints import PMLockBluePrint, PMTypeBluePrint +from instrumentserver.client.proxy import Client +from instrumentserver.gui.instruments import ParameterManagerGui, PMState + +PM_NAME = "parameter_manager" +PM_CLASS = "instrumentserver.params.ParameterManager" +BROADCAST_TIMEOUT = 5000 + + +@pytest.fixture(scope="module", autouse=True) +def pm_working_directory(tmp_path_factory): + """Run the module in its own working directory. + + The Parameter Manager keeps its profile files in the working directory + of the process it lives in, and the GUI's profile selection needs at + least one profile to exist (``switch_to_profile`` raises otherwise). + The module's Server and its Parameter Manager live in the pytest + process, so switching the directory for the module gives them a + private, throwaway profile directory instead of the repository. + """ + workdir = tmp_path_factory.mktemp("pm_gui_profiles") + previous = os.getcwd() + os.chdir(workdir) + yield workdir + os.chdir(previous) + + +@pytest.fixture(scope="module") +def pm(start_server, server_port): + """The module's Parameter Manager Proxy Instrument, with one profile on + disk so the GUI's profile combo has an entry to switch to.""" + cli = Client(port=server_port) + manager = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + manager.toFile() + manager.refresh_profiles() + yield manager + cli.disconnect() + + +@pytest.fixture(autouse=True) +def clean_parameter_manager(pm): + """Leave the shared Parameter Manager without Locks, parameters or + Types before and after every test.""" + + def clean(): + for follower in list(pm.list_locks()): + pm.remove_lock(follower) + for path in list(pm.list()): + pm.remove_parameter(path) + remaining = list(pm.list_types()) + while remaining: + for name in remaining: + try: + pm.remove_type(name) + except ValueError: + pass # still nested in another Type; goes on a later pass + remaining = list(pm.list_types()) + + clean() + yield + clean() + + +@pytest.fixture() +def second_client(start_server, server_port): + """A second Client on the same Server, acting on the GUI's Parameter + Manager from the outside.""" + cli = Client(port=server_port) + yield cli + cli.disconnect() + + +def _second_parameter_manager(second_client): + """The second Client's Proxy Instrument of the same Parameter Manager.""" + return second_client.find_or_create_instrument(PM_NAME, PM_CLASS) + + +def _make_gui(qtbot, pm, server_port): + """Build the Parameter Manager GUI on the module's Parameter Manager, + listening for Broadcasts on the Server's broadcast port.""" + gui = ParameterManagerGui(pm, sub_host="localhost", sub_port=server_port + 1) + qtbot.addWidget(gui) + return gui + + +def _wait_until_broadcasts_arrive(qtbot, gui, second_pm): + """Make sure the GUI's listener receives Broadcasts before the real + assertions run. + + A zmq SUB socket drops everything published before its subscription + has reached the Server's PUB socket (the slow joiner), so the first + Broadcast after the GUI is built can be lost. Probe with throwaway + Types until one is observed; every later Broadcast then arrives. + """ + qtbot.waitUntil(lambda: gui.model.subClient.connected, timeout=BROADCAST_TIMEOUT) + for attempt in range(3): + name = f"gui_probe_type_{attempt}" + second_pm.add_type(name) + try: + qtbot.waitUntil( + lambda: name in gui.state.types, timeout=BROADCAST_TIMEOUT + ) + except Exception: + continue # the probe Broadcast was lost to the slow joiner + second_pm.remove_type(name) + qtbot.waitUntil( + lambda: name not in gui.state.types, timeout=BROADCAST_TIMEOUT + ) + return + raise AssertionError( + "the GUI's listener received no Broadcast; cannot test live updates" + ) + + +def test_pm_state_helpers_work_without_a_server(): + """PMState fills from a local Parameter Manager and applies the + Broadcast payloads, with no Server involved.""" + from instrumentserver.params import ParameterManager + + manager = ParameterManager("pm_state_local") + manager.add_parameter("q01.x", initial_value=1.0, unit="Hz") + manager.add_parameter("q02.x", initial_value=2.0, unit="Hz") + manager.lock("q02.x", "q01.x") + manager.add_type("qubit") + manager.add_type_parameter("qubit", "IF", default=1.0, unit="Hz") + + state = PMState() + assert state.types == {} + assert state.locks == {} + + state.refresh(manager) + assert isinstance(state.types["qubit"], PMTypeBluePrint) + assert state.locks["q02.x"] == PMLockBluePrint( + target="pm_state_local.q01.x", locked=True + ) + + # a pm-lock-update payload replaces the entry; None (the Lock was + # removed) drops it, and dropping an absent entry is not an error + state.apply_lock( + "q02.x", PMLockBluePrint(target="pm_state_local.q01.x", locked=False) + ) + assert state.locks["q02.x"].locked is False + state.apply_lock("q02.x", None) + assert "q02.x" not in state.locks + state.apply_lock("gone.x", None) + + # same for a Type + state.apply_type("qubit", None) + assert "qubit" not in state.types + state.apply_type("gone", None) + + +def test_state_on_construction_holds_types_and_locks_created_before( + qtbot, pm, server_port +): + """Types and Locks that exist before the GUI is built are in + gui.state after the constructor ran.""" + pm.add_parameter("cq01.x", initial_value=1.0, unit="Hz") + pm.add_parameter("cq02.x", initial_value=2.0, unit="Hz") + pm.lock("cq02.x", "cq01.x") + pm.add_type("cqubit") + pm.add_type_parameter("cqubit", "IF", default=3.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + qtbot.waitUntil( + lambda: isinstance(gui.state.types.get("cqubit"), PMTypeBluePrint), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: gui.state.locks.get("cq02.x") + == PMLockBluePrint(target=f"{PM_NAME}.cq01.x", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.state.types["cqubit"].parameters["IF"] == { + "default": 3.0, + "unit": "Hz", + "target": None, + } + finally: + gui.model.stopListener() + + +def test_lock_broadcasts_from_a_second_client_update_the_state( + qtbot, pm, second_client, server_port +): + """A second Client's lock, unlock and remove_lock reach gui.state + through the pm-lock-update Broadcasts.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("q01.x", initial_value=10.0, unit="Hz") + second_pm.add_parameter("q02.x", initial_value=20.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q02.x", "q01.x") + qtbot.waitUntil( + lambda: gui.state.locks.get("q02.x") + == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.unlock("q02.x") + qtbot.waitUntil( + lambda: gui.state.locks.get("q02.x") + == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=False), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.remove_lock("q02.x") + qtbot.waitUntil( + lambda: "q02.x" not in gui.state.locks, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_type_broadcasts_from_a_second_client_update_the_state( + qtbot, pm, second_client, server_port +): + """A second Client's add_type, add_type_parameter and remove_type + reach gui.state through the pm-type-update Broadcasts.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_type("qubit") + qtbot.waitUntil( + lambda: isinstance(gui.state.types.get("qubit"), PMTypeBluePrint), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.add_type_parameter("qubit", "IF", unit="Hz") + qtbot.waitUntil( + lambda: gui.state.types.get("qubit") is not None + and "IF" in gui.state.types["qubit"].parameters, + timeout=BROADCAST_TIMEOUT, + ) + assert gui.state.types["qubit"].parameters["IF"] == { + "default": None, + "unit": "Hz", + "target": None, + } + + second_pm.remove_type("qubit") + qtbot.waitUntil( + lambda: "qubit" not in gui.state.types, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_type_lock_from_a_second_client_updates_types_and_locks( + qtbot, pm, second_client, server_port +): + """A second Client's lock_type_parameter puts the entry's Target into + gui.state.types and the Instances' Locks into gui.state.locks.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + second_pm.add_parameter("dq02.IF", initial_value=2.0, unit="Hz") + # a root-level parameter: the root is never an Instance, so targeting + # it cannot self-lock an Instance parameter + second_pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", default=1.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # an explicit Target, so no Globals parameter is created and no + # parameter-creation Broadcast hits the model's creation branch + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + + qtbot.waitUntil( + lambda: gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared", + timeout=BROADCAST_TIMEOUT, + ) + for follower in ("dq01.IF", "dq02.IF"): + qtbot.waitUntil( + lambda follower=follower: gui.state.locks.get(follower) + == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_second_clients_set_reaches_the_tree_widget( + qtbot, pm, second_client, server_port +): + """A value the second Client sets on a parameter shown in the tree + ends up in the row's widget (D24: onItemNewValue uses + widget._setMethod).""" + pm.add_parameter("sq01.x", initial_value=1.0, unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + second_pm = _second_parameter_manager(second_client) + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + widget = gui.view.delegate.parameters["sq01.x"] + line_edit = widget.paramWidget.input + assert line_edit.text() == "1.0" + + second_pm.sq01.x.set(42) + qtbot.waitUntil( + lambda: line_edit.text() == "42", timeout=BROADCAST_TIMEOUT + ) + finally: + gui.model.stopListener() + + +def test_refresh_all_refills_the_state_from_the_server( + qtbot, pm, second_client, server_port +): + """refreshAll() re-reads every Type and Lock, so the state catches up + with changes made while the listener was not running.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("rq01.x", initial_value=1.0, unit="Hz") + second_pm.add_parameter("rq02.x", initial_value=2.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + # no live updates: the Lock must reach the state through the GUI's + # own refresh + gui.model.stopListener() + second_pm.lock("rq02.x", "rq01.x") + assert "rq02.x" not in gui.state.locks + + gui.refreshAll() + assert gui.state.locks == second_pm.list_locks() + assert gui.state.locks["rq02.x"] == PMLockBluePrint( + target=f"{PM_NAME}.rq01.x", locked=True + ) + finally: + gui.model.stopListener() From d5c5ded0e2b36e91c217e96d860c42caf11e58d1 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 12:46:14 -0500 Subject: [PATCH 069/107] 5.1: fix from review round 1: pin loadProfile refresh and the _setMethod path with tests, drop redundant state.refresh calls, audit rows --- TEST_AUDIT.md | 2 + src/instrumentserver/gui/instruments.py | 4 +- test/pytest/test_pm_gui.py | 76 ++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index c2b5a26..3d31275 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -42,6 +42,8 @@ States: | user_guide/parameter_manager.md (future) | Profiles — loading a file | `ParameterManager.fromFile` accepts `deleteMissing` but never forwards it to `fromParamDict`, so the GUI's `fromFile(filePath=..., deleteMissing=False)` runs with the default `True` | Found during the plan 4.1 review (reviewer-glm, the coder) | gap | Pre-existing; not changed per plan rule 6; fix is a one-line forward plus a test | | user_guide/parameter_manager.md (future) | Profiles — file validation | Both `schemas/parameters.json` and `schemas/parameter_manager_v2.json` use `patternProperties` without `additionalProperties: false`, so a parameter key that does not match `^(\w+)(\.\w+)*$` (e.g. with a space) passes validation and fails later in the loader, and a document listing both a parameter key and a dotted extension of it (`params.q01` and `params.q01.x`), or the bare key `params._globals`, passes validation and fails mid-load in the parameters step (a parameter cannot have child parameters) or silently shadows a submodule; inherited from the legacy reader | Found during the plan 4.1 review (plan-checker-qwen); extended during the plan 4.2 review | gap | Pre-existing in the legacy schema the plan protects; not changed per plan rule 6 | | user_guide/parameter_manager.md (future) | Types — empty Type name | `add_type("")` succeeds (only `_globals` is refused, task 2.1), so a manager can hold an empty-named Type that `toFile` writes and the version-2 reader refuses; such a manager cannot round-trip | Found during the plan 4.2 review (test-reviewer-glm) | gap | Pre-existing since 2.1; not changed per plan rule 6; fix is an empty-name refusal in `add_type` plus a test | +| gui_features.md (future) | Parameter Manager GUI — live creation from another client | `ModelParameters.updateParameter`'s `parameter-creation` branch calls `instrument.update()` and then `nestedAttributeFromString` on the Proxy Instrument; a parameter another Client creates while the GUI is open raises `AttributeError` there (stale Proxy blueprint), so the row never appears | Found during the plan 5.1 work (coder probe, verified pre-existing by all six reviewers) | gap | Pre-existing; not changed per plan rule 6; to be looked at with the 5.x live-update work or in 6.3 | +| user_guide/parameter_manager.md (future) | Profiles — GUI start with no profile file | `ParameterManagerGui.__init__` calls `loadProfile`, which calls `switch_to_profile` with the combo's current text; with no profile file present `switch_to_profile` raises, so the GUI cannot be built until one profile exists | Found during the plan 5.1 work (coder probe) | gap | Pre-existing; not changed per plan rule 6 | ## Manual checks diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 2d89f8b..5447912 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -508,7 +508,7 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: elif bp.action == PM_LOCK_UPDATE: # Locks and Types claim no model item of their own: the Lock # column and the Type tints are separate tasks. The Parameter - # Manager GUI records the change in its PMState (D22). + # Manager GUI records the change in its PMState (D10). self.lockChanged.emit(fullName, bp.value) elif bp.action == PM_TYPE_UPDATE: @@ -876,7 +876,6 @@ def __init__( layout.addWidget(self.addParam) self.connectSignals() self.loadProfile() - self.state.refresh(self.instrument) def connectSignals(self) -> None: super().connectSignals() @@ -961,7 +960,6 @@ def loadFromFile(self, loadFile: Optional[str] = None) -> None: try: self.instrument.fromFile(filePath=loadFile, deleteMissing=False) self.refreshAll() - self.state.refresh(self.instrument) except Exception as e: logger.info(f"Loading failed. {type(e)}: {e.args}") diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 57b1d47..ae14e8e 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -24,10 +24,18 @@ import os import pytest +from qcodes.instrument import InstrumentBase from instrumentserver.blueprints import PMLockBluePrint, PMTypeBluePrint from instrumentserver.client.proxy import Client -from instrumentserver.gui.instruments import ParameterManagerGui, PMState +from instrumentserver.gui.base_instrument import InstrumentSortFilterProxyModel +from instrumentserver.gui.instruments import ( + ItemParameters, + ModelParameters, + ParameterManagerGui, + ParameterManagerTreeView, + PMState, +) PM_NAME = "parameter_manager" PM_CLASS = "instrumentserver.params.ParameterManager" @@ -177,6 +185,51 @@ def test_pm_state_helpers_work_without_a_server(): state.apply_type("gone", None) +class _StubParamWidget: + """Stands in for a ParameterWidget's inner widget of a kind that has no + ``setValue`` (e.g. the QLineEdit of a string parameter or a read-only + QLabel): only the ParameterWidget's ``_setMethod`` reaches it.""" + + def __init__(self): + self.set_via_set_method = [] + + def _setMethod(self, value): + self.set_via_set_method.append(value) + + +class _StubDelegateWidget: + """Stands in for the delegate's ParameterWidget.""" + + def __init__(self): + self.paramWidget = _StubParamWidget() + self.set_via_set_method = [] + + def _setMethod(self, value): + self.set_via_set_method.append(value) + + +def test_on_item_new_value_uses_the_parameter_widget_set_method(qtbot): + """D24 item three: ``ParameterManagerTreeView.onItemNewValue`` delivers + the value through the widget's ``_setMethod``, which every + ParameterWidget kind has — not through ``paramWidget.setValue``, which + only the input widgets have. The stub's paramWidget deliberately has no + ``setValue``, so the old code would raise AttributeError here.""" + stub_instrument = InstrumentBase("pm_tree_stub") + model = ModelParameters(stub_instrument, "parameters", ItemParameters) + view = ParameterManagerTreeView(InstrumentSortFilterProxyModel(model)) + qtbot.addWidget(view) + + widget = _StubDelegateWidget() + assert not hasattr(widget.paramWidget, "setValue") + view.delegate.parameters["stub.x"] = widget + try: + view.onItemNewValue("stub.x", 42) + finally: + model.stopListener() + + assert widget.set_via_set_method == [42] + + def test_state_on_construction_holds_types_and_locks_created_before( qtbot, pm, server_port ): @@ -282,6 +335,27 @@ def test_type_broadcasts_from_a_second_client_update_the_state( gui.model.stopListener() +def test_load_profile_refreshes_the_state_without_broadcasts( + qtbot, pm, second_client, server_port +): + """loadProfile re-reads the Types and Locks from the Parameter Manager + even while the listener is stopped, so no Broadcast can fill the + state: a Type the second Client created before the load must be in + gui.state afterwards.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + second_pm.add_type("pt") + assert "pt" not in gui.state.types # no listener, no Broadcast + + gui.loadProfile() + assert "pt" in gui.state.types + finally: + gui.model.stopListener() + + def test_type_lock_from_a_second_client_updates_types_and_locks( qtbot, pm, second_client, server_port ): From fc555c5b58b4e845dd4012f1b8515394e9f85c4d Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 14:48:24 -0500 Subject: [PATCH 070/107] 5.1: history --- HISTORY_parameter_manager_redesign.md | 49 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 48821a4..09dc9ce 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -698,3 +698,52 @@ None of these were sent, since there was no fix round: - `does_profile_exist` matches by substring (reviewer-qwen, pre-existing). A name that is a substring of an existing profile file passes the check, and the switch then saves, clears and loads a missing file, which `fromFile` only warns about, so the manager ends up empty. `decisions.md` sends it to TEST_AUDIT at 6.3. No `TEST_AUDIT.md` row exists yet. - test-reviewer-glm: the switch-level tests alone can't show that `_clear_all` did the clearing, because the 4.2 reader with `deleteMissing=True` also removes Types the document doesn't define. `test_clear_all_emits_type_updates_then_lock_updates_and_nothing_else` pins the clear step directly. - For Phase 5: the GUI's `loadProfile` now clears Types and Locks on a switch too. + +## 5.1 Client-side state and broadcast handling — 2026-09-25 + +The Parameter Manager GUI now keeps a client-side copy of the Types and Locks. `PMState` is a plain class in `gui/instruments.py`, owned as `ParameterManagerGui.state`, with `types` keyed by Type name and `locks` keyed by the Follower's path relative to the Parameter Manager (the form `list_locks()` returns). `refresh(instrument)` re-reads both, and `apply_lock`/`apply_type` replace one entry or drop it on `None`. `ModelParameters` gained two signals, `lockChanged(str, object)` and `typeChanged(str, object)`. `updateParameter` emits them for `pm-lock-update` and `pm-type-update` without touching any model item, and `connectSignals` wires them to the state. `ParameterManagerTreeView.onItemNewValue` now calls `widget._setMethod(value)` (D24 item three). The new `test/pytest/test_pm_gui.py` has 9 tests. + +### Commit by commit +- `9928d03` `PMState`, the two signals and their routing, the D24 fix and 7 tests. The orchestrator's readings in the coder spec set these rules: + - the state is refreshed at the end of `__init__`, in `refreshAll`, in `loadProfile` and in `loadFromFile` + - the two signals carry `fullName`, the same instrument-name strip the other branches use. For a Lock that is the Follower's relative path and for a Type the bare Type name. Several reviewers checked this against `_broadcast_lock_update` and `_broadcast_type_update` in `params.py`, and checked that `SubClient` delivers `bp.value` as a blueprint or `None`. + - the D24 fix copies `ParametersTreeView.onItemNewValue`, including its `try/except RuntimeError` with a debug log. `ParameterWidget` defines `_setMethod` for every input kind, so no forwarding to the inner widget was needed. + - nothing else in the GUI changes (no Lock column, tints or panels; those are 5.2 onwards) + + The tests use the module-scoped Server on `server_port` and build the GUI with `sub_port=server_port + 1`. A second `Client` drives the Parameter Manager, and every cross-client assertion uses `qtbot.waitUntil`. An autouse fixture moves the module into a temporary working directory, and the `pm` fixture writes one profile there, because `ParameterManagerGui.__init__` calls `loadProfile`. The helper `_wait_until_broadcasts_arrive` handles the zmq slow joiner: it adds throwaway probe Types until one of them reaches the state. The tests: + - `test_pm_state_helpers_work_without_a_server`: `refresh`, `apply_lock` and `apply_type` against a local `ParameterManager` + - `test_state_on_construction_holds_types_and_locks_created_before` + - `test_lock_broadcasts_from_a_second_client_update_the_state`, the plan's named test: `lock`, then `unlock` (`locked` goes False), then `remove_lock` (the key is gone) + - `test_type_broadcasts_from_a_second_client_update_the_state`: `add_type`, `add_type_parameter`, `remove_type` + - `test_type_lock_from_a_second_client_updates_types_and_locks`: the Type entry's `target` and the Instance Locks both appear. It declares an explicit Target, because the default Globals Target would send a `parameter-creation` into the pre-existing crash described under Loose ends. + - `test_a_second_clients_set_reaches_the_tree_widget` + - `test_refresh_all_refills_the_state_from_the_server`, with the listener stopped + + Orchestrator run: ruff clean, 17 in the three named GUI files, 473 in the full suite. +- `d5c5ded` Fix from round 0, four items: + - `test_load_profile_refreshes_the_state_without_broadcasts`: with the listener stopped, the second Client adds a Type, and after `gui.loadProfile()` the Type is in `gui.state`. Nothing had covered the refresh in `loadProfile`. It is the one that matters, because 4.2's profile load sends no `parameter-creation`/`parameter-deletion` and `loadProfile` calls `super().refreshAll()`, which skips the override's refresh (test-reviewer-glm, should-fix). + - `test_on_item_new_value_uses_the_parameter_widget_set_method`: a stub widget with no `paramWidget.setValue` sits in `view.delegate.parameters`, and `onItemNewValue` is called directly on a `ParameterManagerTreeView` over a local `InstrumentBase`. The live test could not tell the fix from the old code: Proxy parameters carry no validators, so every Parameter Manager row is an `AnyInput`, and there `_setMethod` is `paramWidget.setValue` (test-reviewer-qwen, should-fix; test-reviewer-glm, nit). + - The refreshes in `__init__` and `loadFromFile` are gone, since `loadProfile` and `refreshAll` already refresh at those points (reviewer-glm and plan-checker-glm, nits, one-line removals). The comment on the `pm-lock-update` branch now cites D10 instead of D22 (reviewer-glm, nit, folded in). + - Two `gap` rows in `TEST_AUDIT.md` for the defects the coder found (rule 6): "Parameter Manager GUI — live creation from another client" and "Profiles — GUI start with no profile file". + + The commit shows only those two removals and the comment in `src/`. All six approved in re-review, and every raiser confirmed their item fixed. Orchestrator run: ruff clean, 19 in the three named GUI files, 475 in the full suite. + +### Dropped findings +- Nothing asserts that the two PM Broadcasts leave the model's items alone (both test reviewers, nit) → not sent. The risk is low, and 5.2/5.3 will give these actions model effects on purpose. +- `loadFromFile`'s path through `refreshAll` has no test of its own (test-reviewer-qwen, nit) → not sent. It is the same `PMState.refresh` that the `refreshAll` test covers, and 5.4/5.5 exercise files more. +- The `loadProfile` comment blames missing creation/deletion Broadcasts for the Types/Locks refresh, when a profile load does send `pm-type-update`/`pm-lock-update` diffs (plan-checker-qwen, nit) → not sent. The comment is still in `loadProfile`. +- Round 1 nits, none sent: the stub test's `ModelParameters` gets no `sub_port`, so its `SubClient` subscribes to the default Broadcast port 5556. It never binds, but it is a fixed port under D27's rule (test-reviewer-glm, reviewer-qwen). The inner stub's recording list is never read (reviewer-qwen). Also left from round 0: the broad `except Exception` in `_wait_until_broadcasts_arrive` (reviewer-qwen). + +### Questions to Marcos +- The Lumen coin budget kept running out, and when all six round-1 re-reviews were blocked by it the orchestrator stopped to ask what to do → Marcos topped up the budget and said to finish 5.1 and report back. +- The orchestrator flagged its coder-spec readings for Marcos: a plain `PMState` class, where it is refreshed, signals that only route and touch no model item, and the D24 fix copying `ParametersTreeView`. No answer is recorded yet, in the working folder or in `orchestration/RUNS.md`. + +### Loose ends +- `TEST_AUDIT.md`, "Parameter Manager GUI — live creation from another client": a parameter that another Client creates while the GUI is open raises `AttributeError` in `updateParameter`'s `parameter-creation` branch. `nestedAttributeFromString` runs on the stale Proxy blueprint, even though `update()` is called first. All six reviewers confirmed the branch is unchanged from `dc15389`. This means the default-Target flow of `lock_type_parameter` has no GUI-level test until the crash is fixed (plan-checker-glm). +- `TEST_AUDIT.md`, "Profiles — GUI start with no profile file": `switch_to_profile` raises when no profile exists, so the GUI cannot be built until there is one. +- For 5.2/5.3: `lockChanged` and `typeChanged` only update `gui.state`. The Lock column and the Type tints can read the state or connect to the signals. + +### Process notes +- `decisions.md` records the Lumen coin budget running out three times in fix round 1. The first time, one nudge after about 10 minutes got the coder going again. The second time, opencode scheduled a retry in about 48 minutes, and the fix edits sat uncommitted on disk while the orchestrator waited. After the retry the coder ran the suite green, but the budget ran out a third time before it committed, and a nudge got "No healthy endpoints for model glm-5.3-flash". It committed once the provider recovered. +- All six round-1 re-reviews then hit the exhausted budget as soon as they were dispatched, and the orchestrator stopped for Marcos (see Questions). +- Two coder permissions were rejected. The first was a probe run whose `tempfile.mkdtemp()` wrote outside the repo, which the coder reran with its temp directory under `orchestration/5.1/`. The second was a request for `/tmp` in fix round 1. The coder also proved both new tests fail by temporarily changing `instruments.py` from a backup under `orchestration/5.1/`. The orchestrator's diff check of `d5c5ded` showed the file restored, and the backup and logs were deleted. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index fdbb987..761db04 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -549,7 +549,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 5 — GUI -- [ ] **5.1 Client-side state and broadcast handling.** In `gui/instruments.py`: a small +- [x] **5.1 Client-side state and broadcast handling.** In `gui/instruments.py`: a small `PMState` helper on `ParameterManagerGui` holding `types: dict[str, PMTypeBluePrint]` and `locks: dict[str, PMLockBluePrint]`, filled by `list_types`/`get_type`/`list_locks` on load and refresh; `ModelParameters.updateParameter` routes `pm-lock-update` and From 6052b32928ac16f8a4d08ec301e09564982140c7 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Fri, 25 Sep 2026 17:47:56 -0500 Subject: [PATCH 071/107] 5.2: tabs, tints and gutter bands for the Parameter Manager GUI --- src/instrumentserver/gui/instruments.py | 488 +++++++++++++++++++++++- test/pytest/test_pm_gui.py | 374 +++++++++++++++++- 2 files changed, 856 insertions(+), 6 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 5447912..35ac556 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1,6 +1,7 @@ import inspect import logging -from typing import Any, Callable, Dict, Optional, Union, cast +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union, cast from qcodes import Instrument @@ -536,6 +537,62 @@ def insertItemTo( self.newItem.emit(item) +class ModelParameterManager(ModelParameters): + #: Signal() -- + #: Emitted after a Broadcast changed the tree's structure (a parameter + #: was created or removed), so the Parameter Manager GUI can recompute + #: the Type claims that the tints and gutter bands show. + structureChanged = QtCore.Signal() + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # ModelParameters pins the column count at 3 after loading; widen it + # again and give every loaded row the gutter item the narrow count + # dropped + self.setColumnCount(GUTTER_COLUMN + 1) + self.setHorizontalHeaderLabels([self.attr, "unit", "", ""]) + self._ensureGutterItems(self.invisibleRootItem()) + + def _ensureGutterItems(self, parent: QtGui.QStandardItem) -> None: + """Give every row under ``parent`` its gutter item.""" + for row in range(parent.rowCount()): + if parent.child(row, GUTTER_COLUMN) is None: + parent.setChild(row, GUTTER_COLUMN, QtGui.QStandardItem()) + item = parent.child(row, 0) + if item is not None and item.hasChildren(): + self._ensureGutterItems(item) + + def insertItemTo( + self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem + ) -> None: + if item is not None: + # A parameter might not have a unit + unit = "" + if item.element is not None: # type: ignore[attr-defined] + unit = item.element.unit # type: ignore[attr-defined] + unitItem = QtGui.QStandardItem(unit) + extraItem = QtGui.QStandardItem() + gutterItem = QtGui.QStandardItem() + + if parent == self: + rowCount = self.rowCount() + self.setItem(rowCount, 0, item) + self.setItem(rowCount, 1, unitItem) + self.setItem(rowCount, 2, extraItem) + self.setItem(rowCount, GUTTER_COLUMN, gutterItem) + else: + parent.appendRow([item, unitItem, extraItem, gutterItem]) + + self.newItem.emit(item) + + def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: + super().updateParameter(bp) + if bp.action in (PARAMETER_CREATION, PARAMETER_DELETION): + # matching depends on which parameters exist: the tints and + # gutter bands must be recomputed after a structural Broadcast + self.structureChanged.emit() + + class ParametersTreeView(InstrumentTreeViewBase): def __init__( self, @@ -570,6 +627,7 @@ def __init__( parent: Optional[QtWidgets.QWidget] = None, viewType: type = ParametersTreeView, callSignals: bool = True, + modelType: type = ModelParameters, **kwargs: Any, ) -> None: if "instrument" in kwargs: @@ -595,7 +653,7 @@ def __init__( parent=parent, attr="parameters", itemType=ItemParameters, - modelType=ModelParameters, + modelType=modelType, viewType=viewType, callSignals=callSignals, shortcutManager=shortcutManager, @@ -670,6 +728,312 @@ def _clearCurrentParameter(self) -> None: # ----------------- Parameters Manager Classes - Beginning ----------------------------- +# ----------------- Parameter Manager tints - Beginning -------------------------------- + + +#: Logical index of the gutter column of :class:`ModelParameterManager`, +#: whose items carry a row's stack of Types for the +#: :class:`GutterDelegate` to draw. The existing columns keep their +#: indexes: name (0), unit (1), delegate (2). +GUTTER_COLUMN = 3 + +#: Fixed pixel width of the gutter column in the view. +GUTTER_WIDTH = 12 + +#: Data role under which a row's stack of Type names is stored on its +#: gutter item; :class:`GutterDelegate` reads it to draw the bands. +GUTTER_ROLE = cast( + "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 1 +) + +#: The mock's TINTS, light values only (D21: no dark theme): ``tint`` and +#: ``tintAlt`` are the row background of a claimed row (``tintAlt`` for +#: every other sibling row), ``bar`` the colour of its gutter band. The +#: slot of a Type is its index in this list. +TINT_PALETTE: List[Dict[str, str]] = [ + {"tint": "#e8f1fb", "tintAlt": "#dfe9f6", "bar": "#4a7fc1"}, + {"tint": "#e9f4e9", "tintAlt": "#e0ede0", "bar": "#4f9e57"}, + {"tint": "#f6efe4", "tintAlt": "#efe7db", "bar": "#b98a3e"}, + {"tint": "#f9ecec", "tintAlt": "#f2e3e3", "bar": "#b5605f"}, + {"tint": "#e5f4f2", "tintAlt": "#dcece9", "bar": "#3f9490"}, +] + +#: The palette as QColors, in the same slot order. +TINT_COLOURS: List[Dict[str, QtGui.QColor]] = [ + {name: QtGui.QColor(value) for name, value in entry.items()} + for entry in TINT_PALETTE +] + + +@dataclass +class Claim: + """What the tree shows for one row that Types carry (the mock's + ``claims()``): the Claiming Type whose tint the row shows, the Instance + submodule path that claims it, and every Type carrying the row, + outermost first (the gutter draws one band per Type, up to three).""" + + type: str + instance: str + stack: List[str] + + +def _nested_claim_prefixes( + blueprint: PMTypeBluePrint, + types: Mapping[str, PMTypeBluePrint], +) -> Dict[str, str]: + """Map every effective path of the Type ``blueprint`` that a Nested + Type defines to the dotted submodule chain under which its defining + Type sits (the mock's ``at``): a ``qubit`` nesting a ``readout`` at its + submodule ``readout``, with the ``readout`` nesting a ``pulse_window`` + at ``pw``, maps the effective path ``readout.pw.win`` to + ``readout.pw``. + + Mirrors how ``params.py`` expands the effective set + (``_collect_effective``): the entries a Type defines itself are left + out (they claim at the Instance itself) and each Nested Type's own + entries are recorded under the chain that leads to it. + """ + at_by_path: Dict[str, str] = {} + + def walk(blueprint: PMTypeBluePrint, prefix: str, seen: Tuple[str, ...]) -> None: + for submodule, nested_name in blueprint.nested.items(): + if nested_name in seen: + continue # cycles are refused by the Parameter Manager + nested = types.get(nested_name) + if nested is None: + continue + at = prefix + submodule + for path, spec in nested.effective.items(): + if spec.get("from_type") == nested_name: + at_by_path[f"{at}.{path}"] = at + walk(nested, f"{at}.", seen + (nested_name,)) + + walk(blueprint, "", (blueprint.name,)) + return at_by_path + + +def _carries_effective_set( + instance: str, + effective: Mapping[str, Mapping[str, str]], + parameters: Mapping[str, str], +) -> bool: + """Whether the candidate Instance ``instance`` carries every path of + the effective set ``effective`` with the unit the Type declares (D12): + matching requires existence and unit, compared as strings; values are + irrelevant.""" + prefix = f"{instance}." + for path, spec in effective.items(): + if parameters.get(prefix + path) != spec["unit"]: + return False + return True + + +def compute_claims( + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> Dict[str, Claim]: + """The mock's ``claims()`` ported to the client-side state (plan task + 5.2): which Type claims each row of the Parameter Manager tree, and + which stack of Types carries it. + + :param types: the Parameter Manager's Types (``PMState.types``), each + as its :class:`PMTypeBluePrint`. + :param parameters: every parameter row of the tree as ``{path relative + to the Parameter Manager: unit}``. + :return: for every claimed parameter path and submodule path, its + :class:`Claim`. + + Matching mirrors ``ParameterManager.instances_of`` (D12) client-side: + a candidate is every submodule path derived from the parameter paths + (every proper dotted prefix; never the root, never anything under + Globals) and it is an Instance when it carries every effective path + with the declared unit. The Claiming Type is the innermost (the + longest Instance path), then the largest effective set, then the Type + name. A Nested Type claims at and below its submodule, so a row it + defines is claimed by it, with the outer Types behind it in the stack. + """ + # candidate Instances: every proper dotted prefix of a parameter path + candidates = set() + for path in parameters: + segments = path.split(".") + for depth in range(1, len(segments)): + candidate = ".".join(segments[:depth]) + if "_globals" in candidate.split("."): + continue # Globals is excluded from matching at any depth + candidates.add(candidate) + + claims_by_path: Dict[str, List[Tuple[str, str, int]]] = {} + winning: Dict[str, Tuple[str, str, int]] = {} + + def put(path: str, type_name: str, instance: str, size: int) -> None: + # one (Type, Instance, effective set size) claim, as the mock's + # all/map pair; the winner keeps the innermost Instance, then the + # largest effective set, then the Type name + claim = (type_name, instance, size) + claims_by_path.setdefault(path, []).append(claim) + old = winning.get(path) + if old is None or (-len(instance), -size, type_name) < ( + -len(old[1]), + -old[2], + old[0], + ): + winning[path] = claim + + for type_name, blueprint in types.items(): + effective = blueprint.effective + if not effective: + continue # an empty Type has no Instances + size = len(effective) + at_by_path = _nested_claim_prefixes(blueprint, types) + for instance in sorted(candidates): + if not _carries_effective_set(instance, effective, parameters): + continue + # the Instance row itself is claimed by its Type, as in the mock + put(instance, type_name, instance, size) + for path in effective: + # every row at and above the parameter, down to the + # parameter itself, is claimed at the Instance + at = at_by_path.get(path, "") + spec = effective[path] + owner_instance = f"{instance}.{at}" if at else None + at_depth = len(at.split(".")) if at else 0 + segments = path.split(".") + for depth in range(1, len(segments) + 1): + row = f"{instance}.{'.'.join(segments[:depth])}" + put(row, type_name, instance, size) + if owner_instance is not None and depth >= at_depth: + # the Nested Type claims at and below its submodule + put(row, spec["from_type"], owner_instance, size) + + claims: Dict[str, Claim] = {} + for path, path_claims in claims_by_path.items(): + # the stack is every Type carrying the row, outermost first + # (shortest Instance path, then the larger effective set), + # de-duplicated by Type + stack: List[str] = [] + for name in [ + entry[0] + for entry in sorted( + path_claims, key=lambda entry: (len(entry[1]), -entry[2], entry[0]) + ) + ]: + if name not in stack: + stack.append(name) + type_name, instance, _ = winning[path] + claims[path] = Claim(type=type_name, instance=instance, stack=stack) + return claims + + +class TypePalette: + """Assigns the fixed tint palette's slots to the Types the GUI knows. + + A Type keeps its slot while it exists: the slot is assigned when the + GUI first sees the Type (in ``PMState.types`` order after a refresh, + then each new Type from a ``pm-type-update`` Broadcast), it never + changes while the Type is in the state, and it is freed when the Type + is removed. A new Type takes the lowest free slot, or slot 0 when all + five are used (the mock's ``freeTint`` recycles when exhausted). + """ + + def __init__(self) -> None: + self.slots: Dict[str, int] = {} + + def sync(self, type_names: Any) -> None: + """Free the slots of Types that are gone and assign slots to new + ones, in the given creation order. + + :param type_names: the names of the Types the GUI knows + (``PMState.types``). + """ + names = list(type_names) + for name in [known for known in self.slots if known not in names]: + del self.slots[name] + used = set(self.slots.values()) + for name in names: + if name in self.slots: + continue + slot = next( + (index for index in range(len(TINT_PALETTE)) if index not in used), + 0, + ) + self.slots[name] = slot + used.add(slot) + + def colours(self, type_name: str) -> Optional[Dict[str, QtGui.QColor]]: + """The palette entry of the Type ``type_name`` (``tint``, + ``tintAlt`` and ``bar``), or ``None`` when it has no slot.""" + slot = self.slots.get(type_name) + return None if slot is None else TINT_COLOURS[slot] + + def bar_colour(self, type_name: str) -> Optional[QtGui.QColor]: + """The gutter band colour of the Type ``type_name``.""" + colours = self.colours(type_name) + return None if colours is None else colours["bar"] + + +class GutterDelegate(QtWidgets.QStyledItemDelegate): + """Draws the gutter bands of a row's stack of Types into the gutter + column: up to three vertical bands of equal width filling the cell, + one per Type of the stack, outermost first, left to right, in the + Types' ``bar`` colours. A row with no stack paints nothing beyond the + background.""" + + def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: + super().__init__(parent) + # Owned by the Parameter Manager GUI and assigned after the view is + # built; the delegate only reads the Types' colours from it. + self.typePalette: Optional[TypePalette] = None + + def paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.text = "" + # the background first (alternating row or Type tint), then the bands + widget = opt.widget + style = ( + widget.style() if widget is not None else QtWidgets.QApplication.style() + ) + style.drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget + ) + if self.typePalette is None: + return + stack = index.data(GUTTER_ROLE) + if not stack: + return + bandWidth = opt.rect.width() / len(stack) + for band, type_name in enumerate(stack): + colour = self.typePalette.bar_colour(type_name) + if colour is None: + continue + painter.fillRect( + QtCore.QRectF( + opt.rect.x() + band * bandWidth, + opt.rect.y(), + bandWidth, + opt.rect.height(), + ), + colour, + ) + + def sizeHint( + self, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> QtCore.QSize: + return QtCore.QSize( + GUTTER_WIDTH, super().sizeHint(option, index).height() + ) + + +# ----------------- Parameter Manager tints - Ending ----------------------------------- + + class ParameterDeleteDelegate(ParameterDelegate): #: Signal(str) #: Emits the name of the parameter to be deleted when the user presses the delete button. @@ -731,6 +1095,23 @@ def __init__( self.delegate.navFilter = ValueCellNavigationFilter(self) self.setItemDelegateForColumn(2, self.delegate) + + # the gutter column exists only in the Parameter Manager's own model + # (ModelParameterManager) + self.gutterDelegate = GutterDelegate(self) + if self.model().columnCount() > GUTTER_COLUMN: + self.setItemDelegateForColumn(GUTTER_COLUMN, self.gutterDelegate) + header = self.header() + # the gutter moves to visual position 0 with a fixed width; the + # tree branches stay on the name column + header.moveSection(GUTTER_COLUMN, 0) + if header.minimumSectionSize() > GUTTER_WIDTH: + header.setMinimumSectionSize(GUTTER_WIDTH) + header.setSectionResizeMode( + GUTTER_COLUMN, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + header.resizeSection(GUTTER_COLUMN, GUTTER_WIDTH) + self.setTreePosition(0) self.setAllDelegatesPersistent() @QtCore.Slot(object, object) @@ -862,18 +1243,34 @@ def __init__( parent=None, viewType=ParameterManagerTreeView, callSignals=False, + modelType=ModelParameterManager, **kwargs, ) # The client-side cache of the Parameter Manager's Types and Locks. # Created before connectSignals, which wires the model's Broadcast # routing into it. self.state = PMState() + # The tint palette: maps each Type to its slot in TINT_PALETTE; the + # view's gutter delegate reads the colours from it. + self.typePalette = TypePalette() + self.view.gutterDelegate.typePalette = self.typePalette self.profileManager = ProfilesManager(parent=self) self.addParam = AddParameterWidget(parent=self) layout = self.layout() assert isinstance(layout, QtWidgets.QVBoxLayout) layout.insertWidget(0, self.profileManager) layout.addWidget(self.addParam) + # The existing content becomes tab 0 of the tab widget; the Types + # tab stays an empty placeholder until its own task builds it. + self.parametersTab = QtWidgets.QWidget(self) + self.parametersTab.setLayout(self.layout()) + self.typesTab = QtWidgets.QWidget(self) + self.tabs = QtWidgets.QTabWidget(self) + self.tabs.addTab(self.parametersTab, "Parameters") + self.tabs.addTab(self.typesTab, "Types") + outerLayout = QtWidgets.QVBoxLayout(self) + outerLayout.setContentsMargins(0, 0, 0, 0) + outerLayout.addWidget(self.tabs) self.connectSignals() self.loadProfile() @@ -885,7 +1282,8 @@ def connectSignals(self) -> None: self.parameterCreated.connect(self.addParam.clear) self.profileManager.indexChanged.connect(self.loadProfile) self.model.lockChanged.connect(self.state.apply_lock) - self.model.typeChanged.connect(self.state.apply_type) + self.model.typeChanged.connect(self._onTypeChanged) + self.model.structureChanged.connect(self.applyTints) self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) self.shortcutManager.register("clear_add", self.addParam.clear, self) self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) @@ -924,6 +1322,7 @@ def refreshAll(self) -> None: self.instrument.refresh_profiles() self.profileManager.refresh() self.state.refresh(self.instrument) + self.applyTints() def removeParameter(self, fullName: str) -> None: if self.instrument.has_param(fullName): @@ -954,6 +1353,89 @@ def loadProfile(self) -> None: # Broadcasts for the parameters it (re)creates, so the state of the # Types and Locks must be re-read from the Parameter Manager self.state.refresh(self.instrument) + self.applyTints() + + @QtCore.Slot(str, object) + def _onTypeChanged( + self, name: str, type_blueprint: Optional[PMTypeBluePrint] + ) -> None: + """Record the change a ``pm-type-update`` Broadcast reports about + the Type ``name`` in the state, then recompute the tints and gutter + bands it may change.""" + self.state.apply_type(name, type_blueprint) + self.applyTints() + + @QtCore.Slot() + def applyTints(self) -> None: + """Recompute every row's Type claims and repaint the tints and + gutter bands (plan task 5.2). + + Runs after the state was refreshed from the Parameter Manager (on a + model reload), on every ``pm-type-update`` Broadcast, and after a + parameter was created or removed by a Broadcast, since matching + depends on which parameters exist. + """ + self.typePalette.sync(self.state.types) + claims = compute_claims(self.state.types, self._modelParameters()) + self._applyTintsToRows(self.model.invisibleRootItem(), claims) + + def _modelParameters(self) -> Dict[str, str]: + """Every parameter row of the source model as ``{path: unit}``.""" + parameters: Dict[str, str] = {} + self._collectParameters(self.model.invisibleRootItem(), parameters) + return parameters + + def _collectParameters( + self, parent: QtGui.QStandardItem, parameters: Dict[str, str] + ) -> None: + for row in range(parent.rowCount()): + item = parent.child(row, 0) + if item is None: + continue + if item.element is not None: # type: ignore[attr-defined] + # a parameter row; a submodule row's element is None + unitItem = parent.child(row, 1) + parameters[item.name] = "" if unitItem is None else unitItem.text() + if item.hasChildren(): + self._collectParameters(item, parameters) + + def _applyTintsToRows( + self, parent: QtGui.QStandardItem, claims: Dict[str, Claim] + ) -> None: + """Tint every row of ``parent`` that has a Claim with the Claiming + Type's colour on all columns and store its Type stack on the gutter + item; clear the background of the rows without one.""" + for row in range(parent.rowCount()): + rowItems = [parent.child(row, col) for col in range(GUTTER_COLUMN + 1)] + item = rowItems[0] + if item is None: + continue + gutterItem = rowItems[GUTTER_COLUMN] + if gutterItem is None: + gutterItem = QtGui.QStandardItem() + parent.setChild(row, GUTTER_COLUMN, gutterItem) + claim = claims.get(item.name) + colours = ( + self.typePalette.colours(claim.type) if claim is not None else None + ) + if claim is not None and colours is not None: + # claimed rows carry the Claiming Type's tint, alternating + # with tintAlt over the sibling rows so the striping survives + background = colours["tintAlt"] if item.row() % 2 else colours["tint"] + for rowItem in rowItems: + if rowItem is not None: + rowItem.setData( + background, QtCore.Qt.ItemDataRole.BackgroundRole + ) + gutterItem.setData(claim.stack[:3], GUTTER_ROLE) + else: + # a lost claim reverts the row to the default look + for rowItem in rowItems: + if rowItem is not None: + rowItem.setData(None, QtCore.Qt.ItemDataRole.BackgroundRole) + gutterItem.setData([], GUTTER_ROLE) + if item.hasChildren(): + self._applyTintsToRows(item, claims) @QtCore.Slot() def loadFromFile(self, loadFile: Optional[str] = None) -> None: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index ae14e8e..c3e8d3b 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -1,5 +1,5 @@ """Client-side state and Broadcast handling of the Parameter Manager GUI -(plan task 5.1). +(plan task 5.1), plus its tabs, tints and gutter bands (plan task 5.2). The GUI keeps the Parameter Manager's Types and Locks in a ``PMState`` (``ParameterManagerGui.state``), filled from the Parameter Manager on @@ -7,14 +7,20 @@ ``pm-lock-update`` and ``pm-type-update`` Broadcasts the model routes to it. A second Client's changes must reach that state and the tree's value widgets without any polling, so every cross-client assertion waits with -``qtbot.waitUntil``. +``qtbot.waitUntil``. The 5.2 tests cover the tab widget around the +existing view, the pure ``compute_claims`` function and the tint palette +without a Server, and the tints and gutter bands a second Client's Type +edits produce live. Two shapes of the live path are deliberately avoided in these tests, both pre-existing and outside this task's scope: - a parameter another Client creates while the GUI is open makes the model's creation branch resolve it on the GUI's (stale) Proxy Instrument - blueprint, which raises; + blueprint, which raises. Every Type edit below therefore has no creation + side effect: the parameters (with the units the entries declare) exist + before the GUI is built, and the entries land on Instances that already + carry them; - ``lock_type_parameter`` without an explicit Target creates the Globals parameter ``_globals..``, whose ``parameter-creation`` Broadcast hits the same branch. The Type Lock test therefore declares an @@ -26,15 +32,21 @@ import pytest from qcodes.instrument import InstrumentBase +from instrumentserver import QtCore, QtWidgets from instrumentserver.blueprints import PMLockBluePrint, PMTypeBluePrint from instrumentserver.client.proxy import Client from instrumentserver.gui.base_instrument import InstrumentSortFilterProxyModel from instrumentserver.gui.instruments import ( + GUTTER_ROLE, + TINT_COLOURS, + Claim, ItemParameters, ModelParameters, ParameterManagerGui, ParameterManagerTreeView, PMState, + TypePalette, + compute_claims, ) PM_NAME = "parameter_manager" @@ -444,3 +456,359 @@ def test_refresh_all_refills_the_state_from_the_server( ) finally: gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.2: tabs, tints and gutter bands +# --------------------------------------------------------------------------- + + +def _type_blueprint(name, entries, nested=None, registry=None): + """A ``PMTypeBluePrint`` whose effective set is expanded the way + ``params.py`` expands it: the Type's own entries carry itself as + ``from_type``, and every Nested Type's effective set is mounted under + the submodule that requires it, keeping the defining Type.""" + nested = dict(nested or {}) + effective = { + path: {"unit": unit, "from_type": name} for path, unit in entries.items() + } + for submodule, nested_name in nested.items(): + for path, spec in registry[nested_name].effective.items(): + effective[f"{submodule}.{path}"] = dict(spec) + return PMTypeBluePrint( + name=name, + parameters={ + path: {"default": None, "unit": unit, "target": None} + for path, unit in entries.items() + }, + nested=nested, + effective=effective, + ) + + +def test_compute_claims_requires_every_path_with_the_declared_unit(): + """A submodule is an Instance only when it carries every effective path + of the Type with the unit the Type declares (D12).""" + qubit = _type_blueprint("qubit", {"IF": "Hz", "bw": "Hz"}) + + # both paths, both units: q01 matches and claims its rows + claims = compute_claims({"qubit": qubit}, {"q01.IF": "Hz", "q01.bw": "Hz"}) + assert claims["q01.IF"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + assert claims["q01.bw"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + assert claims["q01"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + + # one path missing: no Instance, nothing claimed + assert compute_claims({"qubit": qubit}, {"q01.IF": "Hz"}) == {} + + # a wrong unit excludes the submodule just the same + assert compute_claims({"qubit": qubit}, {"q01.IF": "Hz", "q01.bw": "V"}) == {} + assert compute_claims({"qubit": qubit}, {"q01.IF": "V", "q01.bw": "Hz"}) == {} + + +def test_compute_claims_never_matches_globals_at_any_depth(): + """The Globals submodule and everything under it are excluded from + matching (D12), wherever ``_globals`` appears in the tree.""" + qubit = _type_blueprint("qubit", {"IF": "Hz"}) + claims = compute_claims( + {"qubit": qubit}, + { + "q01.IF": "Hz", + "_globals.qubit.IF": "Hz", + "q01._globals.IF": "Hz", + }, + ) + assert set(claims) == {"q01", "q01.IF"} + + +def test_compute_claims_never_matches_the_root(): + """The root of the Parameter Manager is never an Instance (D12): a + parameter at the root carries the shape, but claims nothing.""" + qubit = _type_blueprint("qubit", {"IF": "Hz"}) + claims = compute_claims({"qubit": qubit}, {"IF": "Hz", "q01.IF": "Hz"}) + assert set(claims) == {"q01", "q01.IF"} + + +def test_compute_claims_of_an_empty_type(): + """An empty Type has no Instances (D12) and claims nothing.""" + qubit = _type_blueprint("qubit", {}) + assert compute_claims({"qubit": qubit}, {"q01.IF": "Hz"}) == {} + + +def test_compute_claims_innermost_nested_type_wins(): + """A Nested Type claims the rows it defines at and below its submodule + (the mock's owner credit): ``readout`` claims ``q01.readout.bw`` with + the ``qubit`` behind it in the stack.""" + readout = _type_blueprint("readout", {"bw": "Hz"}) + qubit = _type_blueprint( + "qubit", {"IF": "Hz"}, nested={"readout": "readout"}, registry={"readout": readout} + ) + claims = compute_claims( + {"readout": readout, "qubit": qubit}, + {"q01.IF": "Hz", "q01.readout.bw": "Hz"}, + ) + assert claims["q01.IF"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + assert claims["q01.readout.bw"] == Claim( + type="readout", instance="q01.readout", stack=["qubit", "readout"] + ) + # the Instance row of the Nested Type is claimed by it as well + assert claims["q01.readout"] == Claim( + type="readout", instance="q01.readout", stack=["qubit", "readout"] + ) + # the outer Instance row stays with the outer Type + assert claims["q01"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + + +def test_compute_claims_of_a_nested_type_nested_two_levels_deep(): + """A Nested Type's own Nested Type extends the submodule chain (the + mock's ``at``): ``pulse_window`` claims at ``q01.readout.pw``.""" + pulse_window = _type_blueprint("pulse_window", {"win": "s"}) + readout = _type_blueprint( + "readout", + {"bw": "Hz"}, + nested={"pw": "pulse_window"}, + registry={"pulse_window": pulse_window}, + ) + qubit = _type_blueprint( + "qubit", {"IF": "Hz"}, nested={"readout": "readout"}, registry={"readout": readout} + ) + claims = compute_claims( + {"pulse_window": pulse_window, "readout": readout, "qubit": qubit}, + {"q01.IF": "Hz", "q01.readout.bw": "Hz", "q01.readout.pw.win": "s"}, + ) + assert claims["q01.readout.pw.win"] == Claim( + type="pulse_window", + instance="q01.readout.pw", + stack=["qubit", "readout", "pulse_window"], + ) + # the Nested Type's submodule row carries the same claim + assert claims["q01.readout.pw"] == Claim( + type="pulse_window", + instance="q01.readout.pw", + stack=["qubit", "readout", "pulse_window"], + ) + assert claims["q01.readout.bw"].type == "readout" + assert claims["q01.IF"].type == "qubit" + + +def test_compute_claims_larger_effective_set_wins(): + """Two non-nested Types covering the same rows: the larger effective + set claims them, both carry the row in the stack.""" + big = _type_blueprint("big", {"a": "Hz", "b": "Hz"}) + small = _type_blueprint("small", {"a": "Hz"}) + claims = compute_claims( + {"big": big, "small": small}, {"q01.a": "Hz", "q01.b": "Hz"} + ) + assert claims["q01.a"] == Claim(type="big", instance="q01", stack=["big", "small"]) + assert claims["q01.b"] == Claim(type="big", instance="q01", stack=["big"]) + assert claims["q01"] == Claim(type="big", instance="q01", stack=["big", "small"]) + + +def test_compute_claims_breaks_ties_by_type_name(): + """Two Types with the same Instance and the same effective set size: + the Type name decides, for determinism.""" + aaa = _type_blueprint("aaa", {"x": "Hz"}) + zzz = _type_blueprint("zzz", {"x": "Hz"}) + claims = compute_claims({"zzz": zzz, "aaa": aaa}, {"q01.x": "Hz"}) + assert claims["q01.x"] == Claim(type="aaa", instance="q01", stack=["aaa", "zzz"]) + + +def test_palette_assigns_slots_in_type_creation_order(): + """The five palette slots go to the first five Types in creation order, + one new Type after the other as the GUI sees them.""" + palette = TypePalette() + palette.sync(["qubit"]) + assert palette.slots == {"qubit": 0} + palette.sync(["qubit", "readout"]) + palette.sync(["qubit", "readout", "mixer", "attenuator", "script"]) + assert palette.slots == { + "qubit": 0, + "readout": 1, + "mixer": 2, + "attenuator": 3, + "script": 4, + } + + +def test_palette_recycles_slot_zero_when_exhausted(): + """A sixth Type reuses slot 0 when all five slots are taken (the mock's + freeTint recycles when exhausted).""" + palette = TypePalette() + palette.sync([f"type{index}" for index in range(5)]) + palette.sync([f"type{index}" for index in range(6)]) + assert palette.slots["type5"] == 0 + + +def test_palette_frees_the_slot_of_a_removed_type(): + """Removing a Type frees its slot and the next new Type takes the + lowest free slot.""" + palette = TypePalette() + palette.sync(["a", "b", "c", "d", "e"]) + palette.sync(["a", "b", "d", "e"]) # c was removed + palette.sync(["a", "b", "d", "e", "f"]) # a new Type arrives + assert palette.slots["f"] == 2 + + +def test_palette_keeps_the_slot_of_an_existing_type_across_updates(): + """A ``pm-type-update`` for an existing Type never changes its colour: + the slot survives the update and an order change in the state.""" + palette = TypePalette() + palette.sync(["a", "b", "c"]) + before = dict(palette.slots) + palette.sync(["a", "b", "c"]) # the update itself + palette.sync(["c", "b", "a"]) # ... and a reordered state + assert palette.slots == before + + +def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): + """The existing widget becomes tab 0 ("Parameters") of a QTabWidget; + tab 1 ("Types") is an empty placeholder for its own task.""" + gui = _make_gui(qtbot, pm, server_port) + try: + assert gui.tabs.count() == 2 + assert gui.tabs.tabText(0) == "Parameters" + assert gui.tabs.tabText(1) == "Types" + parameters_tab = gui.tabs.widget(0) + assert parameters_tab is gui.parametersTab + assert parameters_tab.isAncestorOf(gui.view) + types_tab = gui.tabs.widget(1) + assert types_tab is gui.typesTab + assert types_tab.findChildren(QtWidgets.QWidget) == [] + finally: + gui.model.stopListener() + + +def _row_items(gui, path): + """The four items of the row ``path``: name, unit, delegate, gutter.""" + matches = gui.model.findItems( + path, + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + assert matches, f"no row {path!r} in the model" + item = matches[0] + parent = item.parent() + if parent is None: + return [gui.model.item(item.row(), column) for column in range(4)] + return [parent.child(item.row(), column) for column in range(4)] + + +def test_tints_follow_a_second_clients_type(qtbot, pm, second_client, server_port): + """A Type the second Client adds tints the rows its Instances carry + (including the Instance row itself) and marks the gutter stack; when + its entries and then the Type are removed, the rows lose them again.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q01.readout.bw", initial_value=2.0, unit="Hz") + pm.add_parameter("q02.IF", initial_value=3.0, unit="V") + pm.add_parameter("other.x", initial_value=4.0, unit="s") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # no creation side effect: q01 already carries IF with the unit the + # entry declares, and no other submodule matches + second_pm.add_type("qubit") + second_pm.add_type_parameter("qubit", "IF", unit="Hz") + + def _tint(type_name): + slot = gui.typePalette.slots.get(type_name) + if slot is None: + return None + entry = TINT_COLOURS[slot] + return (entry["tint"], entry["tintAlt"]) + + qtbot.waitUntil( + lambda: _tint("qubit") is not None + and _row_items(gui, "q01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _tint("qubit"), + timeout=BROADCAST_TIMEOUT, + ) + tint = _tint("qubit") + for path in ("q01", "q01.IF"): + for item in _row_items(gui, path)[:3]: # name, unit, delegate + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + assert _row_items(gui, "q01.IF")[3].data(GUTTER_ROLE) == ["qubit"] + # a wrong unit and an unrelated row carry no background + for path in ("q02.IF", "other.x"): + for item in _row_items(gui, path): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None + + # q01 already carries readout.bw: no creation, and the row tints too + second_pm.add_type_parameter("qubit", "readout.bw", unit="Hz") + qtbot.waitUntil( + lambda: _row_items(gui, "q01.readout.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in tint, + timeout=BROADCAST_TIMEOUT, + ) + assert ( + _row_items(gui, "q01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in tint + ) + + # emptying the Type takes the tint and the gutter band away again + second_pm.remove_type_parameter("qubit", "IF") + second_pm.remove_type_parameter("qubit", "readout.bw") + qtbot.waitUntil( + lambda: _row_items(gui, "q01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + is None + and _row_items(gui, "q01.IF")[3].data(GUTTER_ROLE) == [], + timeout=BROADCAST_TIMEOUT, + ) + + # removing the Type clears the last tint and frees the palette slot + second_pm.remove_type("qubit") + qtbot.waitUntil( + lambda: _row_items(gui, "q01.readout.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + is None + and "qubit" not in gui.typePalette.slots, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_refresh_all_recomputes_tints_after_a_model_reload( + qtbot, pm, second_client, server_port +): + """With the listener stopped, a Type the second Client adds still tints + the rows once refreshAll reloads the model and the state.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("eq01.IF", initial_value=1.0, unit="Hz") + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + # no creation side effect: eq01 already carries IF with the unit the + # entry declares + second_pm.add_type("equbit") + second_pm.add_type_parameter("equbit", "IF", unit="Hz") + assert ( + _row_items(gui, "eq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + is None + ) + + gui.refreshAll() + entry = TINT_COLOURS[gui.typePalette.slots["equbit"]] + for item in _row_items(gui, "eq01.IF")[:3]: + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in ( + entry["tint"], + entry["tintAlt"], + ) + assert _row_items(gui, "eq01.IF")[3].data(GUTTER_ROLE) == ["equbit"] + finally: + gui.model.stopListener() From cd392357c5027bc056031a575b4ded4422fe7763 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 09:56:08 -0500 Subject: [PATCH 072/107] 5.2: fix from review round 1: deletion-Broadcast tint test, all-column tint assertions, gutter wiring test, snake_case for the new methods --- src/instrumentserver/gui/instruments.py | 36 +++---- test/pytest/test_pm_gui.py | 121 ++++++++++++++++++++---- 2 files changed, 120 insertions(+), 37 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 35ac556..efabecd 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -551,16 +551,16 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # dropped self.setColumnCount(GUTTER_COLUMN + 1) self.setHorizontalHeaderLabels([self.attr, "unit", "", ""]) - self._ensureGutterItems(self.invisibleRootItem()) + self._ensure_gutter_items(self.invisibleRootItem()) - def _ensureGutterItems(self, parent: QtGui.QStandardItem) -> None: + def _ensure_gutter_items(self, parent: QtGui.QStandardItem) -> None: """Give every row under ``parent`` its gutter item.""" for row in range(parent.rowCount()): if parent.child(row, GUTTER_COLUMN) is None: parent.setChild(row, GUTTER_COLUMN, QtGui.QStandardItem()) item = parent.child(row, 0) if item is not None and item.hasChildren(): - self._ensureGutterItems(item) + self._ensure_gutter_items(item) def insertItemTo( self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem @@ -1282,8 +1282,8 @@ def connectSignals(self) -> None: self.parameterCreated.connect(self.addParam.clear) self.profileManager.indexChanged.connect(self.loadProfile) self.model.lockChanged.connect(self.state.apply_lock) - self.model.typeChanged.connect(self._onTypeChanged) - self.model.structureChanged.connect(self.applyTints) + self.model.typeChanged.connect(self._on_type_changed) + self.model.structureChanged.connect(self.apply_tints) self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) self.shortcutManager.register("clear_add", self.addParam.clear, self) self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) @@ -1322,7 +1322,7 @@ def refreshAll(self) -> None: self.instrument.refresh_profiles() self.profileManager.refresh() self.state.refresh(self.instrument) - self.applyTints() + self.apply_tints() def removeParameter(self, fullName: str) -> None: if self.instrument.has_param(fullName): @@ -1353,20 +1353,20 @@ def loadProfile(self) -> None: # Broadcasts for the parameters it (re)creates, so the state of the # Types and Locks must be re-read from the Parameter Manager self.state.refresh(self.instrument) - self.applyTints() + self.apply_tints() @QtCore.Slot(str, object) - def _onTypeChanged( + def _on_type_changed( self, name: str, type_blueprint: Optional[PMTypeBluePrint] ) -> None: """Record the change a ``pm-type-update`` Broadcast reports about the Type ``name`` in the state, then recompute the tints and gutter bands it may change.""" self.state.apply_type(name, type_blueprint) - self.applyTints() + self.apply_tints() @QtCore.Slot() - def applyTints(self) -> None: + def apply_tints(self) -> None: """Recompute every row's Type claims and repaint the tints and gutter bands (plan task 5.2). @@ -1376,16 +1376,16 @@ def applyTints(self) -> None: depends on which parameters exist. """ self.typePalette.sync(self.state.types) - claims = compute_claims(self.state.types, self._modelParameters()) - self._applyTintsToRows(self.model.invisibleRootItem(), claims) + claims = compute_claims(self.state.types, self._model_parameters()) + self._apply_tints_to_rows(self.model.invisibleRootItem(), claims) - def _modelParameters(self) -> Dict[str, str]: + def _model_parameters(self) -> Dict[str, str]: """Every parameter row of the source model as ``{path: unit}``.""" parameters: Dict[str, str] = {} - self._collectParameters(self.model.invisibleRootItem(), parameters) + self._collect_parameters(self.model.invisibleRootItem(), parameters) return parameters - def _collectParameters( + def _collect_parameters( self, parent: QtGui.QStandardItem, parameters: Dict[str, str] ) -> None: for row in range(parent.rowCount()): @@ -1397,9 +1397,9 @@ def _collectParameters( unitItem = parent.child(row, 1) parameters[item.name] = "" if unitItem is None else unitItem.text() if item.hasChildren(): - self._collectParameters(item, parameters) + self._collect_parameters(item, parameters) - def _applyTintsToRows( + def _apply_tints_to_rows( self, parent: QtGui.QStandardItem, claims: Dict[str, Claim] ) -> None: """Tint every row of ``parent`` that has a Claim with the Claiming @@ -1435,7 +1435,7 @@ def _applyTintsToRows( rowItem.setData(None, QtCore.Qt.ItemDataRole.BackgroundRole) gutterItem.setData([], GUTTER_ROLE) if item.hasChildren(): - self._applyTintsToRows(item, claims) + self._apply_tints_to_rows(item, claims) @QtCore.Slot() def loadFromFile(self, loadFile: Optional[str] = None) -> None: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index c3e8d3b..47cd2c5 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -37,9 +37,12 @@ from instrumentserver.client.proxy import Client from instrumentserver.gui.base_instrument import InstrumentSortFilterProxyModel from instrumentserver.gui.instruments import ( + GUTTER_COLUMN, GUTTER_ROLE, + GUTTER_WIDTH, TINT_COLOURS, Claim, + GutterDelegate, ItemParameters, ModelParameters, ParameterManagerGui, @@ -661,7 +664,9 @@ def test_palette_keeps_the_slot_of_an_existing_type_across_updates(): def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): """The existing widget becomes tab 0 ("Parameters") of a QTabWidget; - tab 1 ("Types") is an empty placeholder for its own task.""" + tab 1 ("Types") is an empty placeholder for its own task; the gutter + column is wired into the view: visual position 0, fixed width, its own + delegate reading the GUI's palette, tree branches on the name column.""" gui = _make_gui(qtbot, pm, server_port) try: assert gui.tabs.count() == 2 @@ -673,6 +678,15 @@ def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): types_tab = gui.tabs.widget(1) assert types_tab is gui.typesTab assert types_tab.findChildren(QtWidgets.QWidget) == [] + + header = gui.view.header() + assert header.visualIndex(GUTTER_COLUMN) == 0 + assert header.sectionSize(GUTTER_COLUMN) == GUTTER_WIDTH + assert isinstance( + gui.view.itemDelegateForColumn(GUTTER_COLUMN), GutterDelegate + ) + assert gui.view.gutterDelegate.typePalette is gui.typePalette + assert gui.view.treePosition() == 0 finally: gui.model.stopListener() @@ -692,6 +706,16 @@ def _row_items(gui, path): return [parent.child(item.row(), column) for column in range(4)] +def _type_tint(gui, type_name): + """The (tint, tintAlt) pair of the Type's palette slot, or ``None`` + while the GUI has not assigned the Type a slot.""" + slot = gui.typePalette.slots.get(type_name) + if slot is None: + return None + entry = TINT_COLOURS[slot] + return (entry["tint"], entry["tintAlt"]) + + def test_tints_follow_a_second_clients_type(qtbot, pm, second_client, server_port): """A Type the second Client adds tints the rows its Instances carry (including the Instance row itself) and marks the gutter stack; when @@ -712,24 +736,19 @@ def test_tints_follow_a_second_clients_type(qtbot, pm, second_client, server_por second_pm.add_type("qubit") second_pm.add_type_parameter("qubit", "IF", unit="Hz") - def _tint(type_name): - slot = gui.typePalette.slots.get(type_name) - if slot is None: - return None - entry = TINT_COLOURS[slot] - return (entry["tint"], entry["tintAlt"]) - qtbot.waitUntil( - lambda: _tint("qubit") is not None + lambda: _type_tint(gui, "qubit") is not None and _row_items(gui, "q01.IF")[0].data( QtCore.Qt.ItemDataRole.BackgroundRole ) - in _tint("qubit"), + in _type_tint(gui, "qubit"), timeout=BROADCAST_TIMEOUT, ) - tint = _tint("qubit") + tint = _type_tint(gui, "qubit") for path in ("q01", "q01.IF"): - for item in _row_items(gui, path)[:3]: # name, unit, delegate + # every column of a claimed row carries the tint: name, unit, + # delegate and gutter + for item in _row_items(gui, path): assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint assert _row_items(gui, "q01.IF")[3].data(GUTTER_ROLE) == ["qubit"] # a wrong unit and an unrelated row carry no background @@ -746,12 +765,10 @@ def _tint(type_name): in tint, timeout=BROADCAST_TIMEOUT, ) - assert ( - _row_items(gui, "q01.IF")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in tint - ) + for item in _row_items(gui, "q01.readout.bw"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + for item in _row_items(gui, "q01.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint # emptying the Type takes the tint and the gutter band away again second_pm.remove_type_parameter("qubit", "IF") @@ -804,7 +821,7 @@ def test_refresh_all_recomputes_tints_after_a_model_reload( gui.refreshAll() entry = TINT_COLOURS[gui.typePalette.slots["equbit"]] - for item in _row_items(gui, "eq01.IF")[:3]: + for item in _row_items(gui, "eq01.IF"): assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in ( entry["tint"], entry["tintAlt"], @@ -812,3 +829,69 @@ def test_refresh_all_recomputes_tints_after_a_model_reload( assert _row_items(gui, "eq01.IF")[3].data(GUTTER_ROLE) == ["equbit"] finally: gui.model.stopListener() + + +def test_a_deletion_broadcast_recomputes_the_tints( + qtbot, pm, second_client, server_port +): + """A parameter-deletion Broadcast from a second Client removes the row + and recomputes the tints: the submodule that stops carrying the whole + set loses its Claim, so the surviving rows show no tint and no gutter + band. Deletion is safe live (the model's deletion branch touches no + Proxy blueprint); creation stays off-limits (TEST_AUDIT trap).""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q01.bw", initial_value=2.0, unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # no creation side effect: q01 already carries IF and bw with the + # units the entries declare + second_pm.add_type("qubit") + second_pm.add_type_parameter("qubit", "IF", unit="Hz") + second_pm.add_type_parameter("qubit", "bw", unit="Hz") + + qtbot.waitUntil( + lambda: _type_tint(gui, "qubit") is not None + and _row_items(gui, "q01.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit"), + timeout=BROADCAST_TIMEOUT, + ) + tint = _type_tint(gui, "qubit") + for item in _row_items(gui, "q01.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + + # removing the parameter makes q01 stop matching, so the whole Type + # claim is gone: the row is removed and the survivors untint + second_pm.remove_parameter("q01.bw") + + def _q01_bw_gone_and_q01_untinted(): + matches = gui.model.findItems( + "q01.bw", + QtCore.Qt.MatchFlag.MatchExactly + | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + if matches: + return False + items = _row_items(gui, "q01.IF") + return ( + all( + item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None + for item in items + ) + and items[3].data(GUTTER_ROLE) == [] + ) + + qtbot.waitUntil( + _q01_bw_gone_and_q01_untinted, timeout=BROADCAST_TIMEOUT + ) + for item in _row_items(gui, "q01"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None + finally: + gui.model.stopListener() From 48c8d7f90c7a0c179f918898490729fb0f4921ec Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 10:12:49 -0500 Subject: [PATCH 073/107] 5.2: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 50 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 09dc9ce..da68e4e 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -747,3 +747,53 @@ The Parameter Manager GUI now keeps a client-side copy of the Types and Locks. ` - `decisions.md` records the Lumen coin budget running out three times in fix round 1. The first time, one nudge after about 10 minutes got the coder going again. The second time, opencode scheduled a retry in about 48 minutes, and the fix edits sat uncommitted on disk while the orchestrator waited. After the retry the coder ran the suite green, but the budget ran out a third time before it committed, and a nudge got "No healthy endpoints for model glm-5.3-flash". It committed once the provider recovered. - All six round-1 re-reviews then hit the exhausted budget as soon as they were dispatched, and the orchestrator stopped for Marcos (see Questions). - Two coder permissions were rejected. The first was a probe run whose `tempfile.mkdtemp()` wrote outside the repo, which the coder reran with its temp directory under `orchestration/5.1/`. The second was a request for `/tmp` in fix round 1. The coder also proved both new tests fail by temporarily changing `instruments.py` from a backup under `orchestration/5.1/`. The orchestrator's diff check of `d5c5ded` showed the file restored, and the backup and logs were deleted. + +## 5.2 Tabs, tints and gutter bands — 2026-09-28 + +`ParameterManagerGui` now puts its existing content on tab 0 ("Parameters", `self.parametersTab`) of a `QTabWidget` (`self.tabs`). Tab 1 ("Types", `self.typesTab`) is an empty placeholder for 5.5. The module-level pure function `compute_claims(types, parameters)` in `gui/instruments.py` ports the mock's `claims()`. It takes `PMState.types` and the model's `{path: unit}` rows and returns a `Claim(type, instance, stack)` for every claimed parameter and submodule row. `TypePalette` hands out the five light `TINT_PALETTE` entries (`tint`, `tintAlt`, `bar`) in Type creation order. `ParameterManagerGui.apply_tints` sets `BackgroundRole` on all four columns of each claimed row and clears it on unclaimed ones. The new `ModelParameterManager` adds a fourth logical column (`GUTTER_COLUMN = 3`), which the view shows at visual position 0, 12 px wide, painted by `GutterDelegate`. `test/pytest/test_pm_gui.py` grew from 9 to 25 tests. + +### Commit by commit +- `6052b32` The tabs, `compute_claims`, the palette, `ModelParameterManager`, `GutterDelegate` and 15 tests. The orchestrator's coder spec set nine readings: + - Matching mirrors `instances_of` client-side. Every effective path must exist with the declared unit, compared as strings. The root, any path with a `_globals` segment and an empty Type never match. + - The Claiming Type is the innermost Instance, then the larger effective set, then the Type name. The name tie-break is not in the mock but matches `types_of`. + - A Nested Type's entries credit the Nested Type at and below its submodule. So `q01.readout.bw` gets `readout` with stack `["qubit", "readout"]`. The helper `_nested_claim_prefixes` derives the submodule chain the way `params.py` expands the effective set. + - Palette slots follow creation order. A removed Type frees its slot, a new Type takes the lowest free slot, and slot 0 is reused when all five are taken (the mock's `freeTint`). + - `tintAlt` goes on odd sibling rows, and the stack is cut to 3 for drawing. + - The gutter column is added without renumbering columns 0/1/2, through a new `modelType` keyword on `InstrumentParameters` that defaults to `ModelParameters`, so the generic instrument GUI is unchanged. + - Every live Type edit must avoid creating a parameter, because of the 5.1 crash in the `parameter-creation` branch (TEST_AUDIT.md). + + The coder added four things of its own: + - The palette helper is `self.typePalette`, not `self.palette`, which would shadow `QWidget.palette`. + - Recompute has three explicit paths: after `state.refresh` in `refreshAll`/`loadProfile`, `typeChanged` → `_onTypeChanged`, and a new `structureChanged` signal emitted after `parameter-creation`/`parameter-deletion` Broadcasts. `newItem` fires mid-load and `modelRefreshed` fires before `state.refresh`, so neither could be used. + - `ModelParameters` pins the model to 3 columns after loading, so `ModelParameterManager` widens it again and `_ensureGutterItems` puts back the gutter items. + - macOS needed `setMinimumSectionSize(GUTTER_WIDTH)`, and the gutter header setup is guarded on `columnCount()`, so 5.1's 3-column D24 stub test still works. + + The tests: eight no-server `compute_claims` tests (unit match, `_globals` at any depth, root, empty Type, innermost Nested Type, two-level nesting ending in stack `["qubit", "readout", "pulse_window"]`, larger set wins, name tie-break), four palette tests, `test_the_parameters_view_moves_into_a_tab_widget`, and two live tests. `test_tints_follow_a_second_clients_type` is the plan's named test. It uses `q01.IF`/`q01.readout.bw` (Hz), `q02.IF` (V) and `other.x`, all created before the GUI. A second Client adds `qubit` with its entries, and the test checks the tint and gutter stack `["qubit"]` on `q01`/`q01.IF`, with none on the wrong-unit and unrelated rows. The tint goes away after `remove_type_parameter` empties the Type and after `remove_type`. `test_refresh_all_recomputes_tints_after_a_model_reload` covers the reload path with the listener stopped. reviewer-qwen confirmed with offscreen Qt probes that the layout re-parenting and the tree branches on the name column work. Orchestrator run: ruff clean, 34 in the three named GUI files, 490 in the full suite. +- `cd39235` Fix from round 0, four items: + - `test_a_deletion_broadcast_recomputes_the_tints`: `q01.IF`/`q01.bw` exist before the GUI, and the second Client's `qubit` claims both. After `remove_parameter("q01.bw")`, the `q01.bw` row is gone, `q01.IF` has no background on any column and an empty gutter stack, and `q01` is untinted. Nothing had tested the `structureChanged` path. Deletion is safe to test live, since only the creation branch crashes. Both test reviewers raised it (should-fix). + - The positive tint checks now cover all four items instead of `[:3]`, as "on all columns" asks. This applies to both live tints and the reload test. test-reviewer-qwen raised it as should-fix and test-reviewer-glm as a nit. The `_tint` closure moved to a module helper, `_type_tint`. + - The tab test now checks the gutter wiring: `visualIndex(GUTTER_COLUMN) == 0`, `sectionSize == GUTTER_WIDTH`, a `GutterDelegate` on the column, `gui.view.gutterDelegate.typePalette is gui.typePalette` and `treePosition() == 0`. Before, deleting `moveSection` or the delegate install would have left every test green. test-reviewer-glm raised it (should-fix). + - Plan rule 8: the six new non-override methods became snake_case (`apply_tints`, `_on_type_changed`, `_model_parameters`, `_collect_parameters`, `_apply_tints_to_rows`, `_ensure_gutter_items`). Overrides (`insertItemTo`, `updateParameter`, `paint`, `sizeHint`) and the signal name `structureChanged` keep camelCase. Both plan checkers raised it as a nit, and each said the reading's `applyTints()` name and the camelCase GUI module argued the other way. The orchestrator sent it anyway because it is a plan rule. The `src/` diff is renames only. + + All six approved in re-review, and every raiser confirmed their item fixed. Orchestrator run: ruff clean, 35 in the three named GUI files, 491 in the full suite. + +### Dropped findings +- The cut of the stack to 3 is never seen, because the deepest test has a stack of exactly 3 (test-reviewer-glm, nit) → not sent. +- The live tests take the expected colour from `gui.typePalette` itself, and the claimed Type always sits in slot 0. The tint/tintAlt parity is not pinned either (test-reviewer-glm, test-reviewer-qwen, nits) → not sent. Reading 8 allowed "tint (or tintAlt)". +- No pixel test of `GutterDelegate.paint` (test-reviewer-qwen nit; the optional part of test-reviewer-glm's wiring finding) → not sent. The fix list said no pixel test was needed. +- `ModelParameterManager.insertItemTo` copies about 10 lines of `ModelParameters.insertItemTo` (reviewer-glm), `TypePalette.sync` is annotated `Any` (reviewer-glm, reviewer-qwen), and the `setColumnCount` round-trip lacks a comment (reviewer-qwen). All nits, not sent; they are still in the code. + +### Loose ends +- A `parameter-update` for a parameter the model does not know adds a row through the base update branch without a recompute, so the row stays untinted until the next one (reviewer-glm, and a reviewer-qwen observation). Logged in `decisions.md` for 5.6 polish. +- `apply_tints` reads units from the model's unit column, which only updates on reload. After a second Client's `set_type_parameter_unit`, the recompute uses stale units (reviewer-glm). Logged for 5.5, when unit edits become reachable from the GUI. +- Convention recorded for 5.3–5.6: new non-override GUI methods are snake_case, while Qt overrides and signals stay camelCase. plan-checker-glm suggested that Marcos settle rule 8 against the GUI's camelCase. The orchestrator decided it without asking him, and no answer from Marcos is recorded. +- The tint parity is stored per row item, so re-sorting the tree can put two rows with the same colour next to each other (reviewer-qwen). The mock does the same. + +### Process notes +- The coder's shell tool timed out after 150 s on every test run it waited on in line, and the coder took this for pytest hangs. Detached runs writing to logs under `orchestration/5.2/` worked. +- Four permission requests were rejected: + - the coder's `pkill -9 -f test_gui_navigation`, which would have killed other agents' runs of that file + - reviewer-qwen's request for `/tmp` + - reviewer-qwen's inline Qt heredoc, which came through truncated in the prompt + - a mistyped path outside the repository from test-reviewer-qwen +- The orchestrator session stopped in fix round 1, after the 2026-09-25 implementation commit, while the coder was waiting on a permission prompt. It resumed on 2026-09-28, and the fix commit and re-reviews followed that day. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 761db04..0170aa0 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -558,7 +558,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session `ParameterManagerTreeView.onItemNewValue` uses `widget._setMethod(value)`. Tests: `test_pm_gui.py` — construct `ParameterManagerGui` against a live server; a second client locks a parameter; `qtbot.waitUntil` the state holds it. -- [ ] **5.2 Tabs, tints and gutter bands.** Wrap the existing widget in a `QTabWidget` +- [x] **5.2 Tabs, tints and gutter bands.** Wrap the existing widget in a `QTabWidget` (Parameters, Types; Types tab empty for now). Port the mock's `claims()` to a pure function over `PMState.types` + the model's paths, producing per row: claiming Type, stack of up to 3 Types. Palette of 5 tint pairs + bar colours assigned by Type creation From c215c5c5b8a8651fcd010a9358c640c88300026c Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 11:08:07 -0500 Subject: [PATCH 074/107] 5.3: Lock column, toggle, context menu, arm strip --- src/instrumentserver/gui/instruments.py | 596 +++++++++++++++++- src/instrumentserver/gui/parameters.py | 21 + src/instrumentserver/resource.py | 415 ++++++++++-- src/instrumentserver/resource.qrc | 2 + src/instrumentserver/resource/icons/lock.svg | 1 + .../resource/icons/unlock.svg | 1 + test/pytest/test_pm_gui.py | 451 ++++++++++++- 7 files changed, 1404 insertions(+), 83 deletions(-) create mode 100644 src/instrumentserver/resource/icons/lock.svg create mode 100644 src/instrumentserver/resource/icons/unlock.svg diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index efabecd..78cac8b 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1,7 +1,18 @@ import inspect import logging from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union, cast +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Mapping, + Optional, + Tuple, + Union, + cast, +) from qcodes import Instrument @@ -548,19 +559,21 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) # ModelParameters pins the column count at 3 after loading; widen it # again and give every loaded row the gutter item the narrow count - # dropped - self.setColumnCount(GUTTER_COLUMN + 1) - self.setHorizontalHeaderLabels([self.attr, "unit", "", ""]) - self._ensure_gutter_items(self.invisibleRootItem()) - - def _ensure_gutter_items(self, parent: QtGui.QStandardItem) -> None: - """Give every row under ``parent`` its gutter item.""" + # dropped, and the Lock column item (plan task 5.3) + self.setColumnCount(LOCK_COLUMN + 1) + self.setHorizontalHeaderLabels([self.attr, "unit", "", "", "locked to"]) + self._ensure_extra_items(self.invisibleRootItem()) + + def _ensure_extra_items(self, parent: QtGui.QStandardItem) -> None: + """Give every row under ``parent`` its gutter item and its Lock + column item.""" for row in range(parent.rowCount()): - if parent.child(row, GUTTER_COLUMN) is None: - parent.setChild(row, GUTTER_COLUMN, QtGui.QStandardItem()) + for column in (GUTTER_COLUMN, LOCK_COLUMN): + if parent.child(row, column) is None: + parent.setChild(row, column, QtGui.QStandardItem()) item = parent.child(row, 0) if item is not None and item.hasChildren(): - self._ensure_gutter_items(item) + self._ensure_extra_items(item) def insertItemTo( self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem @@ -573,6 +586,7 @@ def insertItemTo( unitItem = QtGui.QStandardItem(unit) extraItem = QtGui.QStandardItem() gutterItem = QtGui.QStandardItem() + lockItem = QtGui.QStandardItem() if parent == self: rowCount = self.rowCount() @@ -580,8 +594,9 @@ def insertItemTo( self.setItem(rowCount, 1, unitItem) self.setItem(rowCount, 2, extraItem) self.setItem(rowCount, GUTTER_COLUMN, gutterItem) + self.setItem(rowCount, LOCK_COLUMN, lockItem) else: - parent.appendRow([item, unitItem, extraItem, gutterItem]) + parent.appendRow([item, unitItem, extraItem, gutterItem, lockItem]) self.newItem.emit(item) @@ -1034,11 +1049,271 @@ def sizeHint( # ----------------- Parameter Manager tints - Ending ----------------------------------- +# ----------------- Parameter Manager Locks - Beginning -------------------------------- + + +#: Logical index of the Lock column of :class:`ModelParameterManager` +#: (plan task 5.3). The existing columns keep their indexes: name (0), +#: unit (1), delegate (2), gutter (3). The view shows the Lock column +#: between the unit and the delegate column. +LOCK_COLUMN = 4 + +#: Fixed default pixel width of the Lock column in the view (the user can +#: resize it: the section is Interactive). +LOCK_COLUMN_WIDTH = 140 + +#: The mock's one purple (its ``--log-value`` token): the fill of a row's +#: lock button while its Lock is locked. +LOCK_COLOUR = "#7e5bef" + + +def relative_path(full: str, instrument_name: str) -> str: + """The path relative to the Parameter Manager: ``full`` with the + ``.`` prefix stripped. ``PMLockBluePrint.target`` + stores the full dotted path, while model item names and every string + the GUI shows the user are relative to the Parameter Manager.""" + prefix = f"{instrument_name}." + return full[len(prefix):] if full.startswith(prefix) else full + + +def lock_column_text( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> str: + """The text the Lock column shows for the parameter row ``path`` (a + path relative to the Parameter Manager), computed client-side over the + state's Locks (plan task 5.3; the mock's lock cell). + + A Follower shows its own Lock state: ``locked to `` while + locked, ``unlocked · `` (middle dot) while unlocked, with the + Target relative to the Parameter Manager. A parameter that is no + Follower but the Target of ``N`` Locks — locked and unlocked alike, + the way :meth:`ParameterManager.followers_of` counts — shows + ``target ×N`` (multiplication sign). Every other row shows nothing. + + A row that is both Follower and Target shows its Follower text, which + wins over the Target note (the mock's ``rec.lockedTo || srcNote(p)``). + """ + lock = locks.get(path) + if lock is not None: + # the Follower's own Lock state wins over the Target note + target = relative_path(lock.target, instrument_name) + if lock.locked: + return f"locked to {target}" + return f"unlocked · {target}" + full_path = f"{instrument_name}.{path}" + count = sum(1 for other in locks.values() if other.target == full_path) + if count: + return f"target ×{count}" + return "" + + +def followers_reaching( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> List[str]: + """Paths (relative to the Parameter Manager) of every Follower whose + locked Lock targets the parameter at ``path``, directly or over a + chain of locked Locks. + + Only locked hops count (D7): an unlocked Lock answers ``get`` with its + own value, so the Followers behind it do not see an update made past + it. The walk follows each hop's Target and stops there — no infinite + loop on a cycle, and every Follower appears once. + """ + prefix = f"{instrument_name}." + found: List[str] = [] + seen: set = set() + targets = [prefix + path] + index = 0 + while index < len(targets): + current = targets[index] + index += 1 + for follower, lock in locks.items(): + if not lock.locked or lock.target != current or follower in seen: + continue + seen.add(follower) + found.append(follower) + targets.append(prefix + follower) + return found + + +def rank_lock_targets( + follower: str, + candidates: Iterable[str], + claims: Mapping[str, Claim], +) -> List[str]: + """The arm strip's Target candidates in the mock's completer order. + + ``arm_rel`` is the Follower's path relative to its Instance (the part + behind the Claiming Type's Instance path), or ``None`` when the + Follower is claimed by no Type. Rank 0: the candidate's own relative + path equals ``arm_rel`` (the same leaf on a sibling Instance, the + mock's first pick). Rank 1: ``.`` occurs in the candidate + (a submodule on the way). Rank 2: everything else. Equal ranks order + alphabetically; the Follower itself is never a candidate. Cycles are + not filtered here: the Server refuses them and the arm strip shows its + error text. + """ + follower_claim = claims.get(follower) + arm_rel = ( + follower[len(follower_claim.instance) + 1:] + if follower_claim is not None + else None + ) + + def own_rel(candidate: str) -> Optional[str]: + claim = claims.get(candidate) + if claim is None: + return None + return candidate[len(claim.instance) + 1:] + + ranked: List[Tuple[int, str]] = [] + for candidate in candidates: + if candidate == follower: + continue # the Follower itself is never a candidate + rel = own_rel(candidate) + if arm_rel is not None and rel == arm_rel: + rank = 0 + elif arm_rel is not None and f".{arm_rel}" in candidate: + rank = 1 + else: + rank = 2 + ranked.append((rank, candidate)) + ranked.sort(key=lambda entry: (entry[0], entry[1])) + return [path for _, path in ranked] + + +class LockArmStrip(QtWidgets.QWidget): + """The arm strip under the toolbar while a Lock's Target is being + picked (plan task 5.3): a label naming the Follower, a line edit with + a completer over the ranked candidate paths, a Cancel button and an + error label for the Server's refusal text. + + Picking works three ways: a completion from the popup, Return with the + exact typed path (or the first ranked candidate when the text is not a + path), and clicking a tree row — the last one is wired by the + Parameter Manager GUI, which owns the strip. Cancel is the button or + Escape while the strip or one of its children has focus.""" + + #: Signal(str) + #: Emitted when a Target was picked. The path is relative to the + #: Parameter Manager. + targetPicked = QtCore.Signal(str) + + #: Signal() + #: Emitted when the user cancels the pick (Cancel button or Escape). + cancelled = QtCore.Signal() + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.label = QtWidgets.QLabel(self) + + self.lineEdit = QtWidgets.QLineEdit(self) + self.lineEdit.setPlaceholderText("type part of the target path, or click a row") + + # the completer keeps the ranked candidate order (UnsortedModel) + # and filters it by what the user typed + self.completerModel = QtCore.QStringListModel(self) + self.completer = QtWidgets.QCompleter(self) + self.completer.setModel(self.completerModel) + self.completer.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) + self.completer.setCaseSensitivity( + QtCore.Qt.CaseSensitivity.CaseInsensitive + ) + self.completer.setModelSorting( + QtWidgets.QCompleter.ModelSorting.UnsortedModel + ) + self.lineEdit.setCompleter(self.completer) + + self.cancelButton = QtWidgets.QPushButton("Cancel", self) + + self.errorLabel = QtWidgets.QLabel(self) + self.errorLabel.setStyleSheet( + "QLabel { background-color: red; color: white; font-weight: bold }" + ) + self.errorLabel.setVisible(False) + + layout.addWidget(self.label) + layout.addWidget(self.lineEdit, 1) + layout.addWidget(self.cancelButton) + layout.addWidget(self.errorLabel) + self.setLayout(layout) + + self.completer.activated[str].connect(self.targetPicked) # type: ignore[index] + self.lineEdit.returnPressed.connect(self._on_return_pressed) + self.cancelButton.clicked.connect(self.cancelled) + + self.escShortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Escape"), self) + self.escShortcut.setContext( + QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut + ) + self.escShortcut.activated.connect(self.cancelled) + + @QtCore.Slot() + def _on_return_pressed(self) -> None: + """Pick the exact typed path, or the first ranked candidate when + the typed text is not a path itself (the mock's Enter picks the + first match).""" + text = self.lineEdit.text().strip() + if not text: + return + candidates = self.completerModel.stringList() + if text in candidates: + self.targetPicked.emit(text) + elif candidates: + self.targetPicked.emit(candidates[0]) + else: + self.targetPicked.emit(text) + + def arm(self, follower: str, candidates: List[str]) -> None: + """Arm the strip for the Follower at ``follower``: name it in the + label, load the ranked candidates into the completer, clear the + line edit and any error, show the strip and focus the line edit.""" + self.label.setText(f"Target for {follower}") + self.completerModel.setStringList(candidates) + self.lineEdit.clear() + self.clear_error() + self.setVisible(True) + self.lineEdit.setFocus() + + def show_error(self, text: str) -> None: + """Show the Server's error text on the error label.""" + self.errorLabel.setText(text) + self.errorLabel.setVisible(True) + + def clear_error(self) -> None: + """Hide and clear the error label (the next pick or cancel does + this).""" + self.errorLabel.setText("") + self.errorLabel.setVisible(False) + + def disarm(self) -> None: + """Hide the strip and clear it.""" + self.setVisible(False) + self.lineEdit.clear() + self.clear_error() + + +# ----------------- Parameter Manager Locks - Ending ----------------------------------- + + class ParameterDeleteDelegate(ParameterDelegate): #: Signal(str) #: Emits the name of the parameter to be deleted when the user presses the delete button. removeParameter = QtCore.Signal(str) + #: Signal(str) + #: Emits the name of the parameter whose lock button the user pressed; + #: the Parameter Manager GUI toggles that parameter's Lock. + toggleLock = QtCore.Signal(str) + def createEditor( # type: ignore[override] self, widget: QtWidgets.QWidget, @@ -1052,8 +1327,14 @@ def createEditor( # type: ignore[override] element = item.element # type: ignore[attr-defined] rw = self.makeRemoveWidget(item.name, widget) # type: ignore[attr-defined] + lw = self.makeLockWidget(item.name, widget) - ret = ParameterWidget(parameter=element, parent=widget, additionalWidgets=[rw]) + ret = ParameterWidget( + parameter=element, parent=widget, additionalWidgets=[lw, rw] + ) + # the lock button is kept on the row's ParameterWidget so the + # Parameter Manager GUI can restyle it with the Lock state + ret.lockButton = lw self.parameters[item.name] = ret # type: ignore[attr-defined] ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] @@ -1067,6 +1348,24 @@ def createEditor( # type: ignore[override] return ret + def makeLockWidget( + self, fullName: str, widget: QtWidgets.QWidget + ) -> QtWidgets.QPushButton: + """The per-row lock button. It stays hidden until the row carries a + Lock (a Lock-less row shows no button, as the mock), fills purple + while the Lock is locked, and only :meth:`ParameterManagerGui. + apply_locks` changes its state.""" + w = QtWidgets.QPushButton(QtGui.QIcon(":/icons/lock.svg"), "", parent=widget) + w.setProperty("locked", False) + w.setStyleSheet( + f"QPushButton[locked=\"true\"] {{ background-color: {LOCK_COLOUR} }}" + ) + w.setVisible(False) + keepSmallHorizontally(w) + + w.pressed.connect(lambda: self.toggleLock.emit(fullName)) + return w + def makeRemoveWidget( self, fullName: str, widget: QtWidgets.QWidget ) -> QtWidgets.QPushButton: @@ -1083,6 +1382,15 @@ def makeRemoveWidget( # TODO: Make sure that the refresh button refreshes the profiles as well as the model class ParameterManagerTreeView(InstrumentTreeViewBase): + #: Signal(str) + #: Emitted when the user picks "Lock to…" in the context menu; the + #: Parameter Manager GUI arms the target picker for that parameter. + lockToRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the user picks "Unlock" in the context menu. + unlockRequested = QtCore.Signal(str) + def __init__( self, model: QtCore.QAbstractItemModel, @@ -1111,9 +1419,47 @@ def __init__( GUTTER_COLUMN, QtWidgets.QHeaderView.ResizeMode.Fixed ) header.resizeSection(GUTTER_COLUMN, GUTTER_WIDTH) + if self.model().columnCount() > LOCK_COLUMN: + # the Lock column moves between the unit and the delegate + # column, with a resizable default width + header.moveSection( + header.visualIndex(LOCK_COLUMN), header.visualIndex(2) + ) + header.setSectionResizeMode( + LOCK_COLUMN, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + header.resizeSection(LOCK_COLUMN, LOCK_COLUMN_WIDTH) self.setTreePosition(0) self.setAllDelegatesPersistent() + # the lock actions act on the row the context menu was opened for + # (self.lastSelectedItem, set by the base onContextMenuRequested + # before the menu opens); the Parameter Manager GUI enables and + # disables them in its aboutToShow slot + self.lockToAction = QtWidgets.QAction("Lock to…") + self.lockToAction.triggered.connect(self.onLockToActionTrigger) + self.unlockAction = QtWidgets.QAction("Unlock") + self.unlockAction.triggered.connect(self.onUnlockActionTrigger) + self.contextMenu.addSeparator() + self.contextMenu.addAction(self.lockToAction) + self.contextMenu.addAction(self.unlockAction) + + @QtCore.Slot() + def onLockToActionTrigger(self) -> None: + """The context menu's "Lock to…": arm the target picker for the + row's parameter; a submodule row has no Lock to arm.""" + item = self.lastSelectedItem + if item is not None and item.element is not None: + self.lockToRequested.emit(item.name) + + @QtCore.Slot() + def onUnlockActionTrigger(self) -> None: + """The context menu's "Unlock": unlock the row's Lock; a submodule + row has no Lock to unlock.""" + item = self.lastSelectedItem + if item is not None and item.element is not None: + self.unlockRequested.emit(item.name) + @QtCore.Slot(object, object) def onItemNewValue(self, itemName: str, value: Any) -> None: widget = self.delegate.parameters[itemName] @@ -1271,19 +1617,45 @@ def __init__( outerLayout = QtWidgets.QVBoxLayout(self) outerLayout.setContentsMargins(0, 0, 0, 0) outerLayout.addWidget(self.tabs) + # The arm strip sits right under the toolbar and stays hidden until + # a Lock's Target is being picked (plan task 5.3). The Follower the + # pick is armed for is kept here. + self.armed_follower: Optional[str] = None + self.armStrip = LockArmStrip(self.parametersTab) + parametersLayout = self.parametersTab.layout() + assert isinstance(parametersLayout, QtWidgets.QVBoxLayout) + toolbar_index = parametersLayout.indexOf(self.toolbar) + parametersLayout.insertWidget(toolbar_index + 1, self.armStrip) + self.armStrip.setVisible(False) + # Escape over the tree cancels the pick too (harmless when the + # strip is not armed) + self.viewEscShortcut = QtWidgets.QShortcut( + QtGui.QKeySequence("Escape"), self.view + ) + self.viewEscShortcut.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) + self.viewEscShortcut.activated.connect(self.cancel_arm) self.connectSignals() self.loadProfile() def connectSignals(self) -> None: super().connectSignals() self.view.delegate.removeParameter.connect(self.removeParameter) + self.view.delegate.toggleLock.connect(self._toggle_lock) self.addParam.newParamRequested.connect(self.addParameter) self.parameterCreationError.connect(self.addParam.setError) self.parameterCreated.connect(self.addParam.clear) self.profileManager.indexChanged.connect(self.loadProfile) - self.model.lockChanged.connect(self.state.apply_lock) + self.model.lockChanged.connect(self._on_lock_changed) self.model.typeChanged.connect(self._on_type_changed) self.model.structureChanged.connect(self.apply_tints) + self.model.structureChanged.connect(self.apply_locks) + self.model.itemNewValue.connect(self._on_item_new_value) + self.view.lockToRequested.connect(self.arm_lock) + self.view.unlockRequested.connect(self._unlock) + self.view.contextMenu.aboutToShow.connect(self._update_lock_actions) + self.view.clicked.connect(self._on_view_clicked) + self.armStrip.targetPicked.connect(self.pick_lock_target) + self.armStrip.cancelled.connect(self.cancel_arm) self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) self.shortcutManager.register("clear_add", self.addParam.clear, self) self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) @@ -1323,6 +1695,7 @@ def refreshAll(self) -> None: self.profileManager.refresh() self.state.refresh(self.instrument) self.apply_tints() + self.apply_locks() def removeParameter(self, fullName: str) -> None: if self.instrument.has_param(fullName): @@ -1354,6 +1727,7 @@ def loadProfile(self) -> None: # Types and Locks must be re-read from the Parameter Manager self.state.refresh(self.instrument) self.apply_tints() + self.apply_locks() @QtCore.Slot(str, object) def _on_type_changed( @@ -1365,6 +1739,198 @@ def _on_type_changed( self.state.apply_type(name, type_blueprint) self.apply_tints() + @QtCore.Slot(str, object) + def _on_lock_changed( + self, path: str, lock: Optional[PMLockBluePrint] + ) -> None: + """Record the change a ``pm-lock-update`` Broadcast reports about + the Follower at ``path``, then recompute the Lock column and the + row widgets, and repaint the values the change alters: the + Follower's own and every row whose chain of locked Locks reaches + it, since locking and unlocking change what ``get`` answers.""" + self.state.apply_lock(path, lock) + self.apply_locks() + for follower in [path, *followers_reaching(path, self.state.locks, self.instrument.name)]: + self._refresh_row_widget(follower) + + @QtCore.Slot(object, object) + def _on_item_new_value(self, path: object, value: object) -> None: + """Repaint every Follower whose locked Lock chain reaches the + parameter a ``parameter-update`` Broadcast names (D3: a locked + Follower answers ``get`` with the Target's value, and the + Parameter Manager emits nothing for values). The Broadcast's own + row is refreshed by the base wiring to + ``view.onItemNewValue``; this slot handles the rows behind it.""" + for follower in followers_reaching( + str(path), self.state.locks, self.instrument.name + ): + self._refresh_row_widget(follower) + + def _refresh_row_widget(self, path: str) -> None: + """Re-read the parameter behind the row at ``path`` through the + Proxy, which pulls the Target's value for a locked Follower, and + show it on the row's widget.""" + widget = self.view.delegate.parameters.get(path) + if widget is None: + return + try: + widget.setWidgetFromParameter() + except RuntimeError: + logger.debug( + f"Could not refresh the value of {path}. " + "Object is not being shown right now." + ) + + @QtCore.Slot() + def apply_locks(self) -> None: + """Recompute every parameter row's Lock state from the client-side + state (plan task 5.3): the Lock column text, the lock button's + visibility, tooltip and purple fill, and whether the value renders + read-only. + + Runs after the state was refreshed from the Parameter Manager (on a + model reload), on every ``pm-lock-update`` Broadcast, and after a + parameter was created or removed by a Broadcast. Recomputing all + rows on every change is fine — the tree is small — and keeps one + clear path.""" + self._apply_locks_to_rows(self.model.invisibleRootItem()) + + def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: + """Walk the source model (never the proxy) and set each row's Lock + column text, lock button state and read-only flag.""" + for row in range(parent.rowCount()): + item = parent.child(row, 0) + if item is None: + continue + lockItem = parent.child(row, LOCK_COLUMN) + if lockItem is None: + lockItem = QtGui.QStandardItem() + parent.setChild(row, LOCK_COLUMN, lockItem) + if item.element is None: + # a submodule row carries no Lock state of its own + lockItem.setText("") + else: + lockItem.setText( + lock_column_text( + item.name, self.state.locks, self.instrument.name + ) + ) + widget = self.view.delegate.parameters.get(item.name) + if widget is not None: + self._update_row_lock_widget(item.name, widget) + if item.hasChildren(): + self._apply_locks_to_rows(item) + + def _update_row_lock_widget( + self, path: str, widget: "ParameterWidget" + ) -> None: + """Set one row's lock button and read-only state from the Lock the + state holds for ``path``. A row without a Lock shows no button and + renders its value editable.""" + button = getattr(widget, "lockButton", None) + lock = self.state.locks.get(path) + if lock is None: + if button is not None: + button.setVisible(False) + widget.set_read_only(False) + return + target = relative_path(lock.target, self.instrument.name) + if lock.locked: + tooltip = ( + f"locked to {target} — unlock and go back to its own value" + ) + else: + tooltip = f"unlocked — lock to {target} again" + if button is not None: + button.setToolTip(tooltip) + button.setProperty("locked", lock.locked) + # re-polish so the locked property restyles the button + button.style().unpolish(button) + button.style().polish(button) + button.setVisible(True) + widget.set_read_only(lock.locked) + + @QtCore.Slot(str) + def _toggle_lock(self, path: str) -> None: + """Toggle the Lock of the parameter at ``path`` (the row's lock + button). A refused toggle — relocking would close a cycle (D7) — + shows the Server's error text on the row's alert widget.""" + widget = self.view.delegate.parameters.get(path) + try: + self.instrument.toggle_lock(path) + except Exception as e: + if widget is not None: + widget.alertWidget.setAlert(str(e)) + + @QtCore.Slot(str) + def _unlock(self, path: str) -> None: + """Unlock the Lock of the parameter at ``path`` (the context + menu's "Unlock"). A refused unlock shows the Server's error text + on the row's alert widget.""" + widget = self.view.delegate.parameters.get(path) + try: + self.instrument.unlock(path) + except Exception as e: + if widget is not None: + widget.alertWidget.setAlert(str(e)) + + @QtCore.Slot() + def _update_lock_actions(self) -> None: + """Enable the context menu's lock actions for the row the menu was + opened on: "Lock to…" for every parameter row, "Unlock" only for a + parameter whose Lock in the state is locked.""" + item = self.view.lastSelectedItem + is_parameter = item is not None and item.element is not None + self.view.lockToAction.setEnabled(is_parameter) + self.view.unlockAction.setEnabled( + is_parameter + and item.name in self.state.locks # type: ignore[union-attr] + and self.state.locks[item.name].locked # type: ignore[union-attr] + ) + + def arm_lock(self, follower: str) -> None: + """Arm the target picker for the Follower at ``follower``: the + candidates are every other parameter row of the source model, + ranked like the mock's completer (same relative path inside its + Instance first), and the strip shows under the toolbar. Arming + while already armed re-arms for the new Follower.""" + parameters = self._model_parameters() + claims = compute_claims(self.state.types, parameters) + self.armed_follower = follower + self.armStrip.arm(follower, rank_lock_targets(follower, parameters, claims)) + + def pick_lock_target(self, target: str) -> None: + """Pick ``target`` as the Target of the armed Follower's Lock. A + refused Lock — a cycle (D7) among them — shows the Server's error + text on the strip and stays armed so another target can be picked; + a successful Lock disarms the strip.""" + if self.armed_follower is None: + return + try: + self.instrument.lock(self.armed_follower, target) + except Exception as exc: + self.armStrip.show_error(str(exc)) + else: + self.cancel_arm() + + def cancel_arm(self) -> None: + """Disarm the target picker without picking anything.""" + self.armed_follower = None + self.armStrip.disarm() + + @QtCore.Slot(QtCore.QModelIndex) + def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: + """A row click while the pick is armed chooses that row's parameter + as the Target (the mock's rowClick); a submodule click does + nothing.""" + if self.armed_follower is None: + return + source_index = self.proxyModel.mapToSource(index) + source_index = source_index.sibling(source_index.row(), 0) + item = self.model.itemFromIndex(source_index) + if item is not None and item.element is not None: + self.pick_lock_target(item.name) + @QtCore.Slot() def apply_tints(self) -> None: """Recompute every row's Type claims and repaint the tints and @@ -1406,7 +1972,7 @@ def _apply_tints_to_rows( Type's colour on all columns and store its Type stack on the gutter item; clear the background of the rows without one.""" for row in range(parent.rowCount()): - rowItems = [parent.child(row, col) for col in range(GUTTER_COLUMN + 1)] + rowItems = [parent.child(row, col) for col in range(LOCK_COLUMN + 1)] item = rowItems[0] if item is None: continue diff --git a/src/instrumentserver/gui/parameters.py b/src/instrumentserver/gui/parameters.py index fe5f4e8..f7573c1 100644 --- a/src/instrumentserver/gui/parameters.py +++ b/src/instrumentserver/gui/parameters.py @@ -180,6 +180,10 @@ def __init__( layout.setContentsMargins(1, 1, 1, 1) self.setLayout(layout) + # Rows render read-only while their Lock is locked (plan task 5.3); + # :meth:`set_read_only` keeps this flag current. + self.read_only = False + @QtCore.Slot() def onReturnPressed(self) -> None: """Activates the setButton when the input is selected and enter is pressed.""" @@ -211,6 +215,23 @@ def setWidgetFromParameter(self) -> None: self._setMethod(val) self.parameterSet.emit(val) + def set_read_only(self, read_only: bool) -> None: + """Render the value read-only while the parameter's Lock is locked + (D3): the input and the set button are disabled, since a locked + Follower refuses ``set``, and the get button stays enabled so the + Target's value still refreshes. A parameter without a set method + was constructed with its set button disabled; it is never + re-enabled here. + + :param read_only: whether editing the value is refused. + """ + self.read_only = read_only + # disabling an AnyInput disables its input field and the eval + # toggle with it; every other kind of paramWidget *is* the input + self.paramWidget.setEnabled(not read_only) + if not isinstance(self.paramWidget, QtWidgets.QLabel): + self.setButton.setEnabled(not read_only) + class AnyInput(QtWidgets.QWidget): #: Signal(str) -- diff --git a/src/instrumentserver/resource.py b/src/instrumentserver/resource.py index bdf9e78..90c1e85 100644 --- a/src/instrumentserver/resource.py +++ b/src/instrumentserver/resource.py @@ -258,6 +258,288 @@ \x31\x3d\x22\x31\x32\x22\x20\x79\x31\x3d\x22\x32\x22\x20\x78\x32\ \x3d\x22\x31\x32\x22\x20\x79\x32\x3d\x22\x31\x35\x22\x3e\x3c\x2f\ \x6c\x69\x6e\x65\x3e\x3c\x2f\x73\x76\x67\x3e\ +\x00\x00\x08\xaa\ +\x00\ +\x00\x1f\x81\x78\x9c\xed\x59\xc9\x92\xab\xb8\x12\xfd\x15\x87\xb7\ +\x15\xdd\x06\x0c\xe5\xf2\x8d\x5b\x37\x82\xd9\x60\x06\x83\x99\xa4\ +\x1d\x83\x0b\x0c\x08\x3c\x60\x33\x7c\xfd\x13\x76\x95\xbb\xfa\x76\ +\xbf\xc5\x5b\x3f\xe7\x06\x48\x49\xa9\xcc\x93\x99\x47\x44\xe8\xe7\ +\xf9\x9a\x4e\x3a\x54\x56\xe7\xf7\x69\xd6\x34\x87\x1f\xb3\x59\xdb\ +\xb6\x7f\xb6\xf3\x3f\xeb\x53\x3a\xa3\x08\x82\x98\xe1\x19\xd3\x49\ +\xbb\x4f\x9a\xec\x7d\x4a\xd1\xd3\x49\xb6\xdb\xa7\x59\x73\x7f\xbf\ +\xee\x77\x2d\x57\x77\xef\x53\x62\x42\x4c\x28\x7a\x32\xea\x3e\xf6\ +\x65\xf9\x3e\xad\xea\x6a\x37\x9d\x9c\x9b\x53\x5d\xec\xde\xa7\xf1\ +\xe5\x74\xda\x55\x0d\x5f\x97\xf5\xe9\x4b\xfb\xc7\x97\xcd\x87\xa2\ +\xdc\x57\xbb\x38\x3c\xbc\x4f\x4f\xf5\xa5\x4a\xfe\xa6\xce\xeb\x7d\ +\xf5\xd0\xdf\xfc\xfd\x11\x53\x87\xf0\xe1\xf4\xf8\x71\x73\x19\x85\ +\xd5\xfe\x63\x77\x6e\xa6\xbf\x7e\xa2\x5d\x13\x26\x61\x13\xfe\xfa\ +\x39\x8e\xfe\xf8\x1a\xf9\xc5\xb2\xac\x9f\xa2\x03\x19\xf9\x0a\x7e\ +\x65\x77\x61\xe5\x35\x50\xd6\xfb\x58\x16\x59\xd1\x62\xad\x14\x2b\ +\x8f\x29\x4b\xa3\xb9\x28\x80\x41\x6d\x81\x85\x15\x1c\x8c\xc7\x79\ +\x60\x1c\x14\x93\x63\xe2\x93\x05\x1e\x6b\xf0\x98\xcd\x72\x3c\x56\ +\xf2\x47\x56\xd8\x47\xb1\x65\x90\x31\xa2\x5f\x6f\xeb\x9c\x9a\x01\ +\x03\xa0\x0d\x0a\x16\x7a\x4f\x66\x86\x0f\x48\xcd\xb1\x3a\x7d\xb0\ +\x1a\xd3\xd1\x7b\x7d\x4b\x74\xba\x90\x76\x50\x16\x3b\x23\x37\x0a\ +\xc3\x89\x47\x7f\x84\x92\x7a\xf8\x76\xf8\xf2\x0d\x04\xfa\xbf\xfb\ +\xb6\x65\xb2\x78\x6e\x94\x71\x65\x1f\x22\x8a\x19\xc6\x45\xac\xd6\ +\x8d\xfe\xed\xc7\x57\x5b\x1e\x6d\xd9\x39\x40\xcb\x9e\xe5\x70\x7c\ +\xa3\xd9\x75\xcd\x9a\x6a\xd3\x89\xd4\x2d\xe6\x4b\xe8\x33\x55\x8c\ +\xbc\x22\xf4\xbd\x4b\xc2\x33\x94\xde\xde\xac\x00\x6c\x7f\x08\x15\ +\xc0\x64\x33\xe8\x78\xc0\x83\x1e\xc9\xd6\x73\x0d\xa8\x43\x34\xfa\ +\xc9\xb5\x80\x52\xaf\xf1\xd1\x38\x60\xff\x5e\x21\xb6\x1f\xf9\x12\ +\x11\xf9\x65\x03\xfc\xa4\xd4\xe6\x38\xea\xbe\xa1\x23\xbf\x3b\x62\ +\xfb\x43\x22\x4b\x17\x40\x79\xaa\xbd\x4a\xcf\x3b\x99\x6c\x4d\x54\ +\x1e\xa0\x70\x28\x61\x5e\x22\xbc\x4f\x06\xf7\x04\xad\x3b\x0a\x81\ +\xf1\xa1\x4d\x59\x69\x00\x82\xb9\xd1\x13\xa4\x2e\x83\x56\x77\xdc\ +\xce\x10\xbc\xc2\x18\xb2\x33\xf6\xf3\x0c\x82\x7b\xac\xa1\x5c\xb6\ +\xe1\x8a\xcb\xb0\xee\x92\x88\x4b\x34\x3a\x45\x3f\xf2\xca\x71\x77\ +\xec\x8c\x7d\x34\x57\x7e\xc7\xce\xf9\xc2\x0e\xcc\xef\xb6\xb4\x0a\ +\xf4\xb7\xa8\xb9\x2c\xd7\x2b\xa3\x2e\xfa\x83\xcc\x9c\x3c\xee\xe5\ +\x50\x19\x9d\x31\x18\xb6\x27\x6d\xb4\x71\x1c\x96\x37\x2c\x6b\x94\ +\x3c\xd6\xa6\x47\x15\x01\xdf\x20\x42\x7f\x79\x09\x3f\x31\x8d\xe6\ +\x5c\x19\x8d\x98\x56\xa3\x7f\x52\x03\x03\x1b\xe7\xc8\xcc\x42\xaa\ +\xbc\xc0\xb9\x5a\x42\xb9\x2c\xa3\xca\x1e\xd2\x50\xcd\x93\x40\x3d\ +\xef\x78\x72\x80\x7e\x87\x94\x5b\xee\x55\xb4\xf1\xef\x76\x40\x60\ +\x0c\x30\x50\x6f\xb6\xe3\x7e\x89\x7d\xe3\x32\x0d\x7d\xb7\x61\x5d\ +\x12\x5c\x3b\xa1\x2c\x0d\xa1\x94\xa6\x4a\x02\x97\xbd\x5a\xb8\xaf\ +\xb3\xa2\xda\x5f\x6c\xa9\x36\xce\x89\xb3\x58\x53\x97\x99\xaf\xac\ +\x05\x21\xb6\x20\x1f\xb4\x26\x69\xcf\x13\xbe\x80\x48\xca\x13\xb9\ +\xbc\x46\x55\x9a\x00\x6a\xd9\x68\x48\xba\x24\x72\xd6\x63\xdf\x0f\ +\xa0\x67\xf2\x48\x96\x48\x28\xbb\x97\x78\xa5\x5e\x13\x54\x16\xd0\ +\xb7\x8f\xb1\x2c\xf5\xc0\x27\xcb\x44\xf6\xfa\xf8\x55\xa2\x57\x94\ +\x71\x8d\x30\x8e\x38\x96\x3a\x46\xcb\x36\xf4\x75\x1c\xbb\x7a\x80\ +\x38\x4e\xcd\xc7\x63\x08\xe2\xfa\xf0\xc6\xdc\x57\x89\xcf\x9c\x22\ +\xb4\x9c\x47\xa8\x29\x60\x60\xe4\x31\x2a\xdb\xfb\xfe\x19\xb2\xa8\ +\x2e\x4b\x7c\xbb\x54\x56\x1c\xde\xff\xb6\xa6\x50\x56\x76\x1d\x06\ +\x7a\x0a\x51\x79\x86\x5b\x2e\x4b\x78\x8e\x08\x65\x37\xc5\xf9\xee\ +\x12\xdf\x1b\xf0\xf7\x15\xee\xb9\x4c\x59\x79\x18\x23\x25\x05\x3e\ +\x53\x28\x32\x99\xed\xb6\x5c\x0d\x02\x58\x2a\xb2\xd1\x43\x5c\x93\ +\xd0\xb7\x52\x5c\x03\x69\xe4\x2f\x71\x8d\x63\xdb\xf8\x1b\xc7\x89\ +\xc7\xe1\x21\xc2\xf6\x70\xec\x38\x6e\x5c\x43\x2b\xec\x7b\x60\x5c\ +\x61\x65\xcf\x41\xa0\x96\x16\xae\xe3\xa8\xda\x66\x50\xc6\xf1\xf9\ +\xde\xc3\x47\x9c\xcf\x73\x24\x7e\x61\x67\xa8\x11\xc2\x20\x63\x3d\ +\xdc\xb8\x63\xfb\x2a\x7f\xf1\x04\x77\xe7\x89\xb1\x4f\xd2\xdf\x78\ +\xc2\x36\x3e\x73\x99\x65\x31\x95\x5e\xa0\x2c\x11\x37\x9e\x79\xf4\ +\x9d\x3d\xf4\xb3\x0f\x7f\x4d\x5f\x9c\xb7\x64\x41\x0d\x4a\x9d\x5f\ +\x25\x7d\x5c\xcf\xde\xfb\xce\xcb\x81\xdf\x55\x10\xd7\x2a\x70\x14\ +\xd2\x40\x46\x0b\x7c\xdb\x60\x7f\x17\xd9\xc6\x58\x18\xb5\xcf\xf3\ +\x4b\x31\xb2\x18\xfa\xbc\x89\x1a\x98\xea\x8b\x60\xa8\xae\x6f\x29\ +\xeb\x6a\x51\xf3\xb6\xa7\x08\x70\x14\x83\xba\x94\x0e\xbb\x43\x0b\ +\x4a\xd9\xbe\x8c\x39\x8e\xd0\xbd\x16\x47\x4c\x23\x54\x22\x9c\x33\ +\x22\x44\x1e\x0d\xa8\x8e\x8c\xa9\x31\x76\x93\xab\x91\x37\xf6\x76\ +\x9f\x70\x39\x01\x51\x87\xd7\x24\x44\xc8\x15\x3b\x6e\xdc\x3b\x67\ +\x1e\x3c\xa4\x52\x77\x1e\xd2\x31\x56\xbf\xf3\x10\xfb\xd9\x33\xd8\ +\x6e\x16\xfa\x04\xae\xe7\x1b\xf7\xf1\x02\x35\xf6\xee\xba\x04\x94\ +\x74\x86\x14\xc4\xfd\x2e\xf6\x86\x03\x0f\x78\xef\x2a\x42\x12\x81\ +\xfb\xa6\xdc\x89\xbf\xf5\xcd\xf6\xb3\x47\xe6\x5e\x1f\xe5\x87\xdb\ +\xbb\x99\x97\xb9\x91\xa7\x73\x88\xac\x41\xf3\x45\x12\xe6\x6e\x63\ +\x08\xe2\x60\xf0\x04\xa3\x0f\x4a\xa7\x39\x62\x6b\x0a\x52\x01\x1c\ +\xcc\x88\x94\x45\x1a\x5f\x7d\x56\x61\x6e\xa3\x98\x2c\x59\x79\xb8\ +\x8e\x0e\x07\x9c\x6b\x02\xd7\x58\x0e\xdd\x52\xdc\xf1\x1d\x1d\x05\ +\xec\x6b\xe8\x97\x85\x89\x74\xd2\x14\xbc\x3d\x70\xec\x42\x73\xd4\ +\x0c\x0c\x05\xb6\xcf\x92\x23\xe7\x43\xa4\xe0\x3d\x15\x12\xe4\x98\ +\xcf\x10\x66\xf9\xdc\xa5\x8d\xb9\x8a\x6b\xdf\xcb\x70\xcd\x15\x01\ +\x25\x0d\x31\xe5\xf5\xf7\x5a\x32\x85\x1a\x8d\xe7\x47\x47\x6b\x81\ +\x51\x46\x32\xc8\x6f\xb5\x84\xc0\xf2\xdf\xb8\x5e\xfb\x6f\xfc\x4d\ +\x7d\xe5\x9c\xcd\xc8\x6a\xae\x20\xdb\x39\xbd\x6c\x77\x6b\x46\xf4\ +\x53\x9c\xba\xcd\xf9\x14\x2e\x96\x20\xad\x75\xa3\xe3\x0a\xf5\x83\ +\x5d\x78\xab\x84\x58\x7f\xf1\xd0\xe1\x7f\xe0\xa1\xaf\x1e\xd0\x71\ +\xce\xd4\x07\x07\xa9\xb4\xf9\xca\xc7\x2b\xd1\x4e\x74\x57\x98\x15\ +\x62\x63\x5d\x0f\x4b\xb2\x77\x84\xb9\x60\xda\x4c\xc5\xb5\xf3\x5d\ +\x7c\x51\xe3\x6c\x6d\xa3\x3d\x18\xf3\xb4\x4a\x71\x4e\xbd\x33\xdc\ +\x1b\xb7\x7a\x81\x39\xf9\x65\xff\x1b\x36\xfa\xf5\x13\x83\x5b\x6c\ +\x1a\xb2\x31\x7e\xd2\x63\xcf\x82\x34\x4c\xd5\x60\x88\x37\xc2\xe7\ +\x8c\x98\xb7\x5d\xbf\xf5\xad\xab\xea\x4a\xb1\x03\xde\x78\xc5\xb8\ +\x9c\x12\x47\xd8\x9e\xca\xa2\x2e\x89\x7b\x9d\x91\x1f\x90\xf2\x2e\ +\x38\x26\x6c\x67\xd9\x07\x23\x2f\xa3\x97\xfc\xb3\xd7\xaf\x96\xcf\ +\x10\x21\xe6\xbd\x58\x2e\x73\x45\x1c\x79\x02\x73\xcc\x1c\x62\xec\ +\x8d\x11\xfb\x52\xdf\xd2\xad\x96\x73\xa7\x18\xf3\x3c\x20\xbf\xe9\ +\xf7\x34\xa1\xe5\xe3\x39\xc2\xd2\x0f\x1e\xe0\xb3\xaf\xff\x85\x3c\ +\x6e\xff\xf1\xbf\xf0\xcf\x5a\xbb\xfd\x6f\xb0\xca\xad\xcf\x9b\xb5\ +\xe8\x5b\xca\xb6\x4e\x45\x24\xf3\x12\x64\xd3\xba\xc5\x2c\x2f\xeb\ +\x8a\xc2\xe5\x21\x2b\xb0\xa9\x88\xb3\x6c\xb3\x4c\xc8\xae\x17\x67\ +\x85\x21\xb4\x57\x3a\x9d\xd5\xb2\x95\xba\x4b\x48\xba\x0e\x9e\xb1\ +\xe6\xd2\xf4\x98\x15\xb9\xb9\xb1\x2c\x81\x1d\x38\x55\xb7\xe3\x56\ +\xb2\x80\xe0\x59\xd6\x5a\x6c\x19\xee\x1b\x77\x9f\x15\x89\xe3\xad\ +\x41\xbc\xe8\x7c\x2b\xb3\xa4\x2b\xb2\x9d\x5e\xfe\x1d\x0b\xcc\xe9\ +\x95\x3d\x9e\x3b\xa9\x75\x3b\x83\x70\xcd\xc9\x65\x16\xad\xf4\xd4\ +\x45\xcb\x2b\xe6\x64\xc1\x72\xd8\x9d\xd4\x12\xbd\x91\xb3\xb4\x2e\ +\xc4\x9d\x29\x58\x03\xee\xdb\x70\xd4\x99\xc2\xa8\x03\x9d\xe9\xdc\ +\x75\xba\x68\x75\xd2\xc0\x7a\x5c\x6a\x78\x1c\x5b\x3b\x42\xf1\xfd\ +\x0c\x6a\x53\x57\x54\x05\x7d\x5b\xb4\x38\xf3\xa3\xbf\x82\xd8\x73\ +\xdf\xfd\x4d\x1f\xe7\xc6\x37\xbf\xdc\x1b\x3f\xe0\x7c\x0e\x1c\xd4\ +\x39\x5d\xe6\xfa\xa3\xbc\xd5\xe9\x25\xc6\x4a\xe6\xf9\xcf\xf7\x56\ +\x5c\xb1\x84\xc2\x72\x6a\xf6\xca\xa3\xab\xe6\x72\xa9\x24\x19\x2e\ +\x71\x75\xd7\xa5\xe9\x35\xe2\x6b\x92\x27\xe4\x42\x63\xb6\x97\x36\ +\x20\xb4\x1d\x92\xf6\x67\x4e\x9f\x27\x45\x32\x9b\xc7\x7d\xde\x49\ +\xec\xdc\xaa\x99\x2d\xfd\xfa\x11\x10\xb3\x7d\x06\x08\x0f\x2e\xd0\ +\x47\xb4\x5c\x4b\x0b\x62\xde\x30\x66\xee\x0b\x9c\xaf\xb3\xf4\x88\ +\x61\x22\xb4\x22\x37\x6b\x2d\x91\x6d\x95\x55\x2a\xdc\x63\x5d\x6d\ +\x5d\x51\xc8\x59\x9d\x4b\xeb\x13\x97\x8a\x22\x0b\x36\x75\x90\x2a\ +\x9c\xce\xde\x70\x4f\xc4\xfb\x1a\x1e\x33\x7e\xbb\x1a\x63\xb7\x89\ +\x9c\xe3\xd2\x56\xaa\x59\x77\x28\x57\x7b\x4e\x91\x4c\x28\x9d\xf3\ +\x17\x73\x23\x0e\x4c\xfd\x52\xe9\xab\x20\xd0\x95\x96\x4f\x81\xb2\ +\xae\xa1\x32\xe4\x04\xde\x4f\x17\x60\xcb\xb6\x90\x55\x5a\x3d\xd0\ +\x13\x49\xa5\xb9\x5d\xa3\x69\x1e\x58\x98\xf6\x45\x5c\x56\xf5\x31\ +\x8a\x58\x68\xc2\x6a\x16\xb2\xa7\xa0\x77\x5a\x89\x85\x1f\xeb\xd3\ +\x66\xe8\x24\xea\xba\xa9\x55\xe3\x83\x7c\x71\xf9\x24\x25\x03\x99\ +\xcd\xb9\x36\x20\x87\x64\xe9\xcb\x47\x50\x1c\x11\x46\x46\x3a\xb6\ +\xa4\xd5\x6f\xf3\x13\x39\xa8\x1f\x07\x55\x5f\xbe\x90\x27\x1c\xeb\ +\x76\xa3\xe9\xe6\xc6\xcc\xd7\x97\x7d\x90\xd7\xee\xec\xe0\xba\x3b\ +\x79\x69\xb7\x08\xff\x2f\x71\x19\x94\x0a\xa3\x4a\xff\x71\x4a\x3d\ +\xe5\x29\x4f\x79\xca\x53\x9e\xf2\x94\xa7\x3c\xe5\x29\x4f\x79\xca\ +\x53\x9e\xf2\x94\xa7\x3c\xe5\xff\x51\x36\x10\x58\x1a\xf9\x96\x46\ +\x45\x1a\x96\xc7\xb5\xb5\x30\x89\xcd\x3a\x76\xc4\x5a\x29\xd1\x35\ +\x6a\xce\xe8\x94\x57\xcc\xb2\xe6\x5f\xc2\x0d\xa2\x77\x06\x57\xbe\ +\xee\x7d\xb0\x01\xd6\xf5\xb4\x78\xad\xd6\x3a\x57\x38\xa7\x70\x1b\ +\x5e\x89\xfc\xed\x92\x3a\xdb\x0b\x8d\x68\xf9\x12\x45\xf5\xd9\x8c\ +\xdf\x7f\xce\xfe\x7e\x27\xfc\x73\xf6\xd7\x6d\xf1\x69\x17\x37\x93\ +\xee\x7d\x3a\x9f\x4e\xfa\xf7\x29\x49\x3e\x6e\xbf\xc9\xb7\xbf\x6e\ +\xbf\x47\xf5\xa9\xbb\xdd\x5e\x9f\xfa\xf1\x81\x4d\x8c\x0b\x7f\xfd\ +\x3c\x84\x4d\x36\x49\xde\xa7\xfa\x62\x42\x92\xde\x22\x64\x26\xcc\ +\x64\xbc\x1e\x27\x27\xcb\x3f\x97\x7f\x90\xe3\xc4\x71\x0a\x7e\x9c\ +\xaf\xe9\xaf\xff\x00\x91\xcf\x15\xcf\ +\x00\x00\x08\xae\ +\x00\ +\x00\x1f\x82\x78\x9c\xed\x59\x59\x8f\xea\x3a\x12\xfe\x2b\x88\xd7\ +\xd6\x0c\x49\x08\x74\x73\x75\xfa\x48\x76\x36\x12\xb2\x90\x00\x59\ +\xfc\x96\x05\xb2\x3a\x61\x09\x64\xf9\xf5\xe3\x40\x37\xf7\xdc\x73\ +\xef\x3c\xcc\xf3\x50\x12\x22\x29\xdb\xe5\xaa\xaf\xaa\x3e\x47\xf2\ +\x8f\xcb\x2d\x1e\xb5\xb8\x28\x2f\x9f\xe3\xa4\xae\x8f\x7f\x4c\x26\ +\x4d\xd3\xfc\xbb\x99\xfe\xbb\x3a\xc7\x13\x86\xa2\xa8\x09\x99\x31\ +\x1e\x35\x69\x54\x27\x9f\x63\x86\x1d\x8f\x92\x7d\x1a\x27\xf5\xe3\ +\xf9\x96\xee\x1b\x58\xb5\x9f\x63\x6a\x44\x8d\x18\x76\x34\xe8\x0e\ +\x69\x51\x7c\x8e\xcb\xaa\xdc\x8f\x47\x97\xfa\x5c\xe5\xfb\xcf\x71\ +\x78\x3d\x9f\xf7\x65\xcd\x55\x45\x75\xfe\xd6\xfe\xeb\xdb\xe6\x53\ +\x51\xa4\xe5\x3e\xf4\x8f\x9f\xe3\x73\x75\x2d\xa3\xbf\xa8\xb3\x2a\ +\x2d\x9f\xfa\xbb\xbf\x7f\x84\xcc\xd1\x7f\x3a\x3d\xbc\xdc\x5d\xc6\ +\x7e\x99\x1e\xf6\x97\x7a\xfc\xf3\x07\xde\xd7\x7e\xe4\xd7\xfe\xcf\ +\x1f\xc3\xe8\x1f\xdf\x23\x3f\x01\x00\x4e\x8c\x8f\x74\xe0\xc8\xe4\ +\x11\xec\xfd\xd2\xae\x91\xa4\x75\xa1\x24\x00\xc1\x04\x66\x4c\x94\ +\xa7\x18\xb0\x78\x2a\xf0\x5e\xaf\x34\x9e\x49\x14\x10\x85\xc3\x3c\ +\x6f\x18\x14\xa2\x53\xe4\xd0\x39\x19\xab\xc9\x98\x05\x20\x47\x94\ +\xdc\x09\xf0\x69\x10\x9a\x3a\x1d\x62\x76\x7e\x5f\xb7\xad\xa6\x7a\ +\xa6\xe4\x9a\xa3\x4d\x75\x8e\xea\x0c\x3e\xca\xd4\xad\x95\x19\x8e\ +\x56\x1b\xdb\x22\xd3\x3a\x3a\xf5\xb0\x95\x7b\x5b\x58\x78\x0e\xca\ +\x0d\xde\x1b\xfc\xe1\x0b\xe6\xe9\xdb\xf1\xdb\x37\xcf\xd5\xfe\xd9\ +\xb7\xcd\x2c\x09\xa7\x7a\x11\x96\xd6\x31\x60\x66\xfd\xb0\x08\xa8\ +\xed\xe0\x5f\x3a\x3c\x5a\xd2\x60\xcb\xca\x3c\xbc\xe8\x00\x24\xf1\ +\x0d\x66\x57\x15\x30\x94\xba\x15\x98\x7b\xcc\x57\xdf\x99\x95\x21\ +\xb6\x73\xdf\xb1\xaf\x11\x37\x63\xb4\xe6\x6e\xc5\x23\xf6\x7b\x7f\ +\x39\x3d\x9c\xf9\x78\xfb\x21\xb4\x6e\xff\x66\xed\x45\xf6\xf6\xb6\ +\xa1\xa9\x01\x90\xc6\x63\x94\x5b\x78\xd2\x8f\xc4\xbf\x39\x22\xf6\ +\x03\x47\xa4\x02\xa7\xa8\x3d\x27\x2a\xd4\xa9\xce\xa0\xae\x66\x03\ +\xa7\x3d\x11\xfb\x7d\x24\x89\x57\x8f\xb1\x15\x6b\x19\x5f\xf6\x12\ +\xdd\x18\xb8\x38\x22\xfe\x98\x6b\xdb\x70\x6a\x6c\xc5\x54\xef\x28\ +\x46\xdf\xee\xa6\xea\xd6\x9c\xea\x92\x56\x7b\x59\xce\x68\x1c\x45\ +\xe9\xcc\x8e\x46\x58\x68\x35\xf2\xf3\x18\xf1\x42\xfc\xbc\x78\xee\ +\x23\x56\x5f\x2a\x1a\x7f\x09\x13\xa2\xbb\x46\xc2\x02\x0f\x4e\xb1\ +\xcf\xbc\x42\xf8\xc0\x4e\x4f\x83\xa9\xfc\x3b\x76\xdb\x6f\xec\xbc\ +\xe9\xc3\x96\x5a\x7a\xdd\x3d\x6a\x98\x64\x5a\xa9\x57\x91\xfe\x1e\ +\xa1\x40\xd8\xb0\xda\xfb\x09\x7e\xbc\xd9\x5b\xf9\xfc\xa6\x0e\xb8\ +\xa0\xe2\x8e\x65\x85\xa3\xe7\xda\xf8\xa4\x60\xcf\xd1\x29\xdf\x59\ +\x5c\xfd\x2f\x4c\x83\x29\x2c\x82\x01\xd3\x72\xf0\x4f\xac\x91\x6b\ +\x91\x1c\x19\x89\xcf\x14\x57\x34\x55\x0a\x24\x15\x45\x50\x5a\x7d\ +\xec\x2b\x59\xe4\x2a\x97\x3d\x47\xf7\xc8\x69\xb1\x7c\xcf\xbd\x82\ +\xd7\xce\xc3\x8e\xe7\xea\x3d\x72\x95\xbb\xed\xb0\x5b\x10\xdf\x60\ +\xa2\xe2\x5f\x6d\x98\xd7\x28\xd3\x73\x5f\x12\x7b\x5f\x8c\xe3\xa4\ +\x59\x05\xdc\xd5\xd0\xd7\x8b\xc9\x4d\x02\x66\xd7\x9f\x11\xd8\x18\ +\xd9\xd5\x97\x58\xf4\xb6\x51\xe5\xdb\xb2\x99\x31\xe7\xd5\x56\x0c\ +\xb9\x1c\x61\x31\x8b\xa4\xe2\x16\x94\x71\xe4\x31\x8b\x5a\xc5\xe2\ +\x35\x92\x92\x8e\xf8\x7e\xf4\xba\x59\x16\x48\x22\x8d\xa4\xdd\x35\ +\x5c\x2a\xb7\x08\x17\x39\x72\xac\x53\x28\x89\x9d\xe7\xd0\x45\x24\ +\xd9\x5d\x38\x17\xd9\x25\xa3\xdf\x02\x82\x23\x89\xa5\x0a\xf1\xa2\ +\xf1\x1d\x8d\xc4\xae\x1c\x11\x89\x53\x75\xc8\x18\x46\xa4\x3e\xec\ +\x21\xf7\x65\xe4\xcc\xce\x01\x5e\x4c\x03\x5c\xe7\xc8\xd5\xb3\x10\ +\x17\xcd\x63\xff\x04\x9b\x4c\x9b\x44\x8e\x55\xc8\x4b\x48\xf6\xbf\ +\xaf\xc9\xe5\xa5\x55\xf9\xae\x16\x23\x5c\x5c\xd0\x06\x26\x11\x07\ +\x29\x5f\xda\xc5\x24\xdf\x6d\xe4\xd8\x3d\x79\xbf\xa1\x14\x26\xf2\ +\xd2\x26\x18\xc9\xb1\xe7\xcc\x72\x59\xa2\x93\xfd\x06\x56\x9e\x8b\ +\x0a\x59\xd2\x3b\x44\x6a\x12\x39\x66\x4c\x6a\x20\x0e\x9c\x05\xa9\ +\x71\x62\x9b\xbc\x93\x38\xc9\x38\x3a\x06\xc4\x1e\x89\x9d\xc4\x4d\ +\x6a\x68\x49\x7c\x77\xf5\x1b\x2a\xad\xa9\xe7\x2a\x85\x49\xea\x38\ +\x28\x37\x09\x92\x48\x7c\x8e\xfd\xf4\x91\xe4\xf3\x12\x08\xdf\xd8\ +\xe9\x4a\x80\xf5\xcb\xa0\x47\xeb\xdd\xd0\xbe\xf2\x9f\x3c\x01\x1f\ +\x3c\x31\xf4\x49\xfc\x1b\x4f\x58\xfa\x57\x2e\x93\x24\x64\xe2\x2b\ +\x92\x44\xea\xce\x33\xdf\x7d\x07\xd4\x26\xb9\xda\xd9\x49\xd1\x75\ +\x01\x16\x40\x31\x2f\xe9\x7e\x1a\x0e\xeb\xc1\xa3\xef\xec\xcc\x73\ +\xda\x12\x91\x5a\xf5\xb6\x32\xad\x63\xbd\xf1\x1c\x4b\x07\xbf\x8b\ +\x64\x11\x2c\xf4\xca\xe1\xb8\xa3\x95\x43\xc7\x64\x70\x4e\x45\x47\ +\xda\x04\xa5\x73\xed\xc4\xcd\x76\xcf\x60\x3f\xb2\xeb\xdb\xee\x50\ +\xb7\x3d\xb3\x9a\xb0\xe0\x6c\x49\xd6\x75\xc8\x71\x80\x1f\xb5\x38\ +\x60\x1a\xe0\x02\x93\x9c\x51\x3e\xb6\x59\x8f\x69\xe9\x90\x19\x62\ +\x37\x60\x85\xed\xa1\xb7\xbb\x08\x66\x14\xc2\x2d\x59\x13\x51\x3e\ +\xcc\xf7\x70\xd8\x3b\x9b\x3d\x79\x48\x61\x1e\x3c\xa4\x11\xac\x7e\ +\xe7\x21\xf0\xd5\x33\xc4\x6e\xe2\x3b\x14\xa9\xe7\x3b\xf7\x71\x3c\ +\x33\xf4\xee\xaa\x18\xfa\x1e\x31\x88\xf4\xbb\xd0\xe9\x5b\x74\x24\ +\x7b\x97\x01\x16\x29\xd2\x37\xc5\x5e\xf8\xad\x6f\x36\x5f\x3d\x32\ +\xb5\xbb\x20\x3b\xde\x9f\x8d\x2c\x64\x34\x6c\xb6\x5e\x1f\x52\xea\ +\x56\x66\x75\x46\xab\x09\xc7\xcc\xbc\x8e\x9a\x11\x2e\xee\x55\x47\ +\x49\x91\x64\x25\x9a\x64\x27\x08\x9b\xac\x9e\x7e\xd9\x28\x09\xb7\ +\x31\xb3\x24\x5a\xda\xa4\x8e\x8e\x47\x92\x6b\x8a\xd4\x58\x86\x76\ +\x85\xb0\xe7\x5a\x36\x70\xc1\xdc\x77\x8a\xdc\xc8\x94\x44\xc3\x5a\ +\x8b\x24\xaf\x21\x35\x9f\xe9\xbc\x5c\xeb\x7c\xc8\x1a\x1c\x9d\x22\ +\x47\x26\xf6\x05\xc6\xeb\xa3\xd4\xdb\x0a\x33\x4d\x12\x53\x54\x2a\ +\xa4\xf6\xed\x84\xd4\x5c\xee\x32\x62\x1f\x32\x76\xf7\xa8\x25\x83\ +\xaf\xf0\x70\x7e\xb4\xac\xea\xea\x45\x20\x79\xd9\xbd\x96\xb0\xb7\ +\xf8\x27\xae\x57\xff\x1b\x7f\x33\xcf\x9c\x2f\xc1\xf1\xb2\x62\x59\ +\x6a\x32\x7d\xfb\xf0\x20\xaf\x1a\x75\x0e\xe5\xb9\x31\xab\x82\xa4\ +\x9c\xc9\x97\xf4\xe3\xc0\x97\xfe\xe5\xa8\xd9\xcd\xea\x9b\x87\x8e\ +\xff\x03\x0f\x7d\xf7\x80\x46\x72\xa6\x3c\x39\x68\xdd\x45\xf2\x1a\ +\x08\xd5\x12\xa9\x6b\xc3\xb0\xba\xf4\x6a\x7c\x2c\x22\xff\xc8\x9f\ +\xfa\x22\xf7\x05\x91\xc9\x9a\x78\x75\xb1\x26\x25\x9d\xa6\xde\x90\ +\xa7\x65\x4c\x72\x6a\x5f\x50\xaa\xdf\xeb\x05\x65\xf4\xb7\xfd\x5f\ +\xb0\xd1\x6e\x5f\x18\xdc\x63\x53\xb1\x45\xf0\x13\x9f\x7b\xce\x2b\ +\xee\xb8\x9d\xe4\x1f\xa8\x8b\xcd\x33\xf2\x3a\xc8\x54\x5c\x7f\xd1\ +\x19\x35\x92\x96\x39\xb5\x88\x16\xe1\x5a\x78\x6f\xa0\xde\x1b\x24\ +\x87\xf7\x3a\xa3\x0f\x88\xb1\xaf\x24\x26\x62\x67\xd1\xb9\x03\x2f\ +\xe3\xb7\xec\xab\xd7\x6f\xa6\x33\xa3\x7c\xc2\x7b\xa1\x54\x64\xb2\ +\x30\xf0\x04\xe1\x98\x29\x22\xd8\xeb\x03\xf6\x85\xb6\x61\x1b\x35\ +\x83\xe7\x90\xf0\xbc\x47\xff\xa2\x4f\x59\x4a\xcd\x86\x73\x04\xb0\ +\x4f\x1e\xe0\x92\xef\xef\x85\x2c\x6c\xfe\xf6\xbd\xf0\xf7\x5a\xbb\ +\x7f\x6f\x00\xf9\xde\xe7\xf5\x4a\x70\x4c\x79\x53\xc5\x02\x96\x38\ +\x11\x81\xb8\x6a\xe2\x58\x96\x34\x59\x86\x99\x0f\x78\x10\x0b\x1c\ +\x48\x2c\x30\xf3\xc1\xea\xfd\x22\xcf\x28\x75\xce\xc6\x93\x4a\x32\ +\xe3\xdd\x02\xd1\xbb\x2d\x99\xb1\x82\x71\x7c\x4a\xf2\xcc\x58\x9b\ +\x26\x0f\x7a\xa8\x68\x56\xd8\x88\xa6\xc7\xdb\xa6\xb9\x12\x9a\x19\ +\xfc\x85\xbb\x2f\xb2\x08\x39\xb3\x17\xae\x1a\xd7\x48\x80\xde\x09\ +\xa0\xd5\x8a\xbf\x62\x41\x38\xbd\xb4\x86\x73\x27\x36\xef\x67\x10\ +\xa9\x39\xa9\x48\x82\xa5\x16\xef\xf0\xe2\x46\x38\x99\x37\xb7\x60\ +\x2f\x36\x54\xa7\x67\x80\xd5\xf8\xb0\x35\x78\xb3\x27\x7d\xeb\x0f\ +\x3a\x83\x1f\x74\x5e\x6b\x6c\x1f\x3a\x4d\x30\x5b\xb1\x07\x36\x8c\ +\x75\x1b\x82\x6a\xcb\xe7\xbf\x9e\x41\x4d\xbc\x13\x14\x5e\xdb\xe4\ +\x0d\xc9\xfc\xe0\x2f\x2f\x74\xf0\x57\x7f\xe3\xe7\xb9\xf1\x8b\x5f\ +\xbb\x3b\x3f\x90\x7c\xf6\x10\x69\x50\x93\x60\x77\x92\x36\x1a\xbb\ +\x20\x58\x49\x1c\xf7\xf5\xdc\x08\x4b\x40\xc9\x00\x2a\xc9\x9c\xc3\ +\x37\x75\x07\x63\x51\xd4\x77\xd4\x6d\xb7\x2a\x0c\xbb\x16\xe6\x51\ +\x16\xd1\xef\xea\x6c\x73\x6d\x5c\x4a\xdd\x63\x31\xbd\x40\x6d\x1a\ +\xe5\xd1\x64\x1a\x76\x59\x2b\x82\xa9\x59\xcd\x36\xec\xfc\xe0\x52\ +\x93\x34\xf1\x28\x1b\xbd\xe3\x43\xb0\x58\x89\xef\xd4\xb4\x9e\x19\ +\x99\xc3\x43\x47\x03\xec\x80\x61\xc4\x37\x02\x9c\x34\xa6\x00\x1a\ +\x79\x19\xf3\x8f\x58\x97\x9b\x9d\xc0\x67\x40\x83\x71\x75\x86\xb1\ +\x20\x00\x6f\x5d\xb9\xb1\x0c\x35\x70\xc7\x3d\x12\x1e\x6b\x38\x0d\ +\x80\x66\x39\xc4\x6e\x51\x19\x84\x71\x23\x56\x60\xd7\x17\xcb\x14\ +\xca\xa2\x81\xc4\x4b\xf6\x66\xac\x85\x7e\x56\xbd\x95\xda\xd2\x75\ +\x35\xb9\xe1\x62\x4f\x5e\x55\x48\xee\x33\x8a\xec\xa7\xf1\xa8\x01\ +\x0d\x02\x72\xa3\xb9\x5a\x24\x2a\x2c\xdc\xd7\xaa\x6a\x7b\xef\x86\ +\x75\x15\x16\x65\x75\x0a\x02\x80\x0c\x54\x4e\x7c\x70\x76\xbb\x6d\ +\x23\x02\x74\x58\x9d\xd7\x7d\x2b\x32\xb7\x75\xa5\xe8\x07\xfa\x6d\ +\xc7\x45\x31\xed\x4a\x20\x83\x8d\x4b\xf7\xd1\xc2\x91\x4e\x5e\x7e\ +\xc2\x04\x19\xf1\xd4\xd0\x66\xb7\xc9\xce\x74\xaf\x1c\x8e\x8a\xb6\ +\x78\xa3\xcf\x24\xd6\xcd\x5a\xd5\x8c\xb5\x91\xad\xae\xa9\x9b\x55\ +\xbb\xc9\x71\xb7\xdb\x4b\x0b\xab\xc1\xe4\x7b\x09\x26\x48\xcc\xf5\ +\x32\xfe\xdb\x29\xf5\x92\x97\xbc\xe4\x25\x2f\x79\xc9\x4b\x5e\xf2\ +\x92\x97\xbc\xe4\x25\x2f\x79\xc9\x4b\x5e\xf2\x92\xff\x47\x59\x23\ +\xcf\x14\x4f\xa6\x20\x30\x66\x9f\x38\x27\x46\xf1\xda\x0f\x7d\x75\ +\xde\xe9\x5e\x50\xb8\x55\xc1\xcd\x8d\x44\x98\x1c\x0e\x94\x7a\x8a\ +\xf0\x3e\xaf\xa3\xaa\x9f\x23\x94\x9f\xb6\x40\xc7\xec\x44\xdf\xe4\ +\xb6\xaf\x6f\x9b\x37\xef\x6d\x5e\x7f\xb0\x11\x3e\xcc\xe1\xfe\x48\ +\x5b\x65\x67\x5e\xe3\xcf\x1f\x93\xbf\xde\x09\xff\x98\xfc\x79\x5b\ +\x7c\xde\x87\xf5\xa8\xfd\x1c\x4f\xc7\xa3\xee\x73\x4c\xd3\xcf\xdb\ +\x6f\xfa\xe3\xcf\xdb\xef\x41\x7d\x6e\xef\xb7\xd7\xe7\x6e\xf8\x23\ +\x26\x86\x85\x3f\x7f\x1c\xfd\x3a\x19\x45\x9f\x63\xed\x7d\x44\xd3\ +\xf6\xbb\x3f\x1b\xcd\x46\xc3\xf5\x38\x3d\xa2\xc9\xdf\x8d\x1d\x66\ +\x0e\x73\xc8\xdf\xe5\x16\xff\xfc\x0f\xfa\xcf\x14\x61\ \x00\x00\x01\x33\ \x3c\ \x73\x76\x67\x20\x78\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\ @@ -1462,6 +1744,14 @@ \x05\x77\x54\xa7\ \x00\x6c\ \x00\x6f\x00\x61\x00\x64\x00\x2e\x00\x73\x00\x76\x00\x67\ +\x00\x0a\ +\x05\x95\xd0\xa7\ +\x00\x75\ +\x00\x6e\x00\x6c\x00\x6f\x00\x63\x00\x6b\x00\x2e\x00\x73\x00\x76\x00\x67\ +\x00\x08\ +\x05\x9e\x54\xa7\ +\x00\x6c\ +\x00\x6f\x00\x63\x00\x6b\x00\x2e\x00\x73\x00\x76\x00\x67\ \x00\x08\ \x05\xa8\x57\x87\ \x00\x63\ @@ -1528,79 +1818,85 @@ qt_resource_struct_v1 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x00\x18\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ +\x00\x00\x00\x18\x00\x02\x00\x00\x00\x16\x00\x00\x00\x03\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x04\ \x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x01\x7d\ \x00\x00\x00\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x03\x21\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xfe\ \x00\x00\x00\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x56\ -\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xc6\ -\x00\x00\x00\xe2\x00\x01\x00\x00\x00\x01\x00\x00\x0f\xfd\ -\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x13\xa9\ -\x00\x00\x01\x14\x00\x00\x00\x00\x00\x01\x00\x00\x17\x48\ -\x00\x00\x01\x2a\x00\x00\x00\x00\x00\x01\x00\x00\x18\xd4\ -\x00\x00\x01\x3e\x00\x00\x00\x00\x00\x01\x00\x00\x1a\x48\ -\x00\x00\x01\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xc1\ -\x00\x00\x01\x7a\x00\x00\x00\x00\x00\x01\x00\x00\x25\x7f\ -\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x45\ -\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xd8\ -\x00\x00\x01\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3b\x79\ -\x00\x00\x01\xf2\x00\x00\x00\x00\x00\x01\x00\x00\x3d\x0d\ -\x00\x00\x02\x0c\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x87\ -\x00\x00\x02\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x46\x28\ -\x00\x00\x02\x54\x00\x00\x00\x00\x00\x01\x00\x00\x4c\x78\ +\x00\x00\x00\xcc\x00\x01\x00\x00\x00\x01\x00\x00\x0e\xc6\ +\x00\x00\x00\xe6\x00\x01\x00\x00\x00\x01\x00\x00\x17\x74\ +\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x20\x26\ +\x00\x00\x01\x12\x00\x01\x00\x00\x00\x01\x00\x00\x21\x5d\ +\x00\x00\x01\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x25\x09\ +\x00\x00\x01\x44\x00\x00\x00\x00\x00\x01\x00\x00\x28\xa8\ +\x00\x00\x01\x5a\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x34\ +\x00\x00\x01\x6e\x00\x00\x00\x00\x00\x01\x00\x00\x2b\xa8\ +\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x31\x21\ +\x00\x00\x01\xaa\x00\x00\x00\x00\x00\x01\x00\x00\x36\xdf\ +\x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00\x3b\xa5\ +\x00\x00\x01\xe2\x00\x00\x00\x00\x00\x01\x00\x00\x45\x38\ +\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00\x4c\xd9\ +\x00\x00\x02\x22\x00\x00\x00\x00\x00\x01\x00\x00\x4e\x6d\ +\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x4f\xe7\ +\x00\x00\x02\x5c\x00\x00\x00\x00\x00\x01\x00\x00\x57\x88\ +\x00\x00\x02\x84\x00\x00\x00\x00\x00\x01\x00\x00\x5d\xd8\ " qt_resource_struct_v2 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x00\x18\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x00\x18\x00\x02\x00\x00\x00\x16\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x04\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ \x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x01\x7d\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ \x00\x00\x00\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x03\x21\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xfe\ -\x00\x00\x01\x9e\x65\xc8\xf1\x97\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ \x00\x00\x00\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x56\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xc6\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x00\xe2\x00\x01\x00\x00\x00\x01\x00\x00\x0f\xfd\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x13\xa9\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x01\x14\x00\x00\x00\x00\x00\x01\x00\x00\x17\x48\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x01\x2a\x00\x00\x00\x00\x00\x01\x00\x00\x18\xd4\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x01\x3e\x00\x00\x00\x00\x00\x01\x00\x00\x1a\x48\ -\x00\x00\x01\x9e\x8e\x14\x03\xf8\ -\x00\x00\x01\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xc1\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x01\x7a\x00\x00\x00\x00\x00\x01\x00\x00\x25\x7f\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x45\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xd8\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x01\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3b\x79\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x01\xf2\x00\x00\x00\x00\x00\x01\x00\x00\x3d\x0d\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x02\x0c\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x87\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x02\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x46\x28\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x02\x54\x00\x00\x00\x00\x00\x01\x00\x00\x4c\x78\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x00\xcc\x00\x01\x00\x00\x00\x01\x00\x00\x0e\xc6\ +\x00\x00\x01\xa0\xe8\xb4\x4b\xc5\ +\x00\x00\x00\xe6\x00\x01\x00\x00\x00\x01\x00\x00\x17\x74\ +\x00\x00\x01\xa0\xe8\xb4\x4b\xc5\ +\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x20\x26\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\x12\x00\x01\x00\x00\x00\x01\x00\x00\x21\x5d\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x25\x09\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\x44\x00\x00\x00\x00\x00\x01\x00\x00\x28\xa8\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\x5a\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x34\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\x6e\x00\x00\x00\x00\x00\x01\x00\x00\x2b\xa8\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x31\x21\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\xaa\x00\x00\x00\x00\x00\x01\x00\x00\x36\xdf\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00\x3b\xa5\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\xe2\x00\x00\x00\x00\x00\x01\x00\x00\x45\x38\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00\x4c\xd9\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x02\x22\x00\x00\x00\x00\x00\x01\x00\x00\x4e\x6d\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x4f\xe7\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x02\x5c\x00\x00\x00\x00\x00\x01\x00\x00\x57\x88\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x02\x84\x00\x00\x00\x00\x00\x01\x00\x00\x5d\xd8\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ " -qt_version = [int(v) for v in QtCore.qVersion().split(".")] +qt_version = [int(v) for v in QtCore.qVersion().split('.')] if qt_version < [5, 8, 0]: rcc_version = 1 qt_resource_struct = qt_resource_struct_v1 @@ -1608,17 +1904,10 @@ rcc_version = 2 qt_resource_struct = qt_resource_struct_v2 - def qInitResources(): - QtCore.qRegisterResourceData( - rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data - ) - + QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) def qCleanupResources(): - QtCore.qUnregisterResourceData( - rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data - ) - + QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) qInitResources() diff --git a/src/instrumentserver/resource.qrc b/src/instrumentserver/resource.qrc index 35d5186..39a7e7f 100644 --- a/src/instrumentserver/resource.qrc +++ b/src/instrumentserver/resource.qrc @@ -21,5 +21,7 @@ resource/icons/trash.svg resource/icons/trash-crossed.svg resource/icons/folder.svg + resource/icons/lock.svg + resource/icons/unlock.svg \ No newline at end of file diff --git a/src/instrumentserver/resource/icons/lock.svg b/src/instrumentserver/resource/icons/lock.svg new file mode 100644 index 0000000..175b292 --- /dev/null +++ b/src/instrumentserver/resource/icons/lock.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo3NjJkMWM3NC0yODdjLTRjOWMtOTljMy1iYmRkYTBlYWZkODYAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaH3frDgT8ExXz+ReF4v+S10AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpkMTc3OTFiNy02NTU3LTQ3NGMtYjk2MC00N2U1ZmExMmExY2FscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNodN7dZbES4M7qB8+VTIr+LwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgghwKbCuONP9/vGAQyzrZASOjuaG4Z+SLIvHw52rKTFcCkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaALwhuVjqJNNEBlAJQsie3cAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCCpRkBWQ2mk0dp1QAnWuyFSTe2madVtvUftxz2K/4ArRGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBj0Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjc2MmQxYzc0LTI4N2MtNGM5Yy05OWMzLWJiZGRhMGVhZmQ4Ni9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjJhMmMxZGYwLWNjNDItNDc4OC1iZWIzLWE2YzdiYTE5MGFiZnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCHApsK440/3+8YBDLOtkBI6O5obhn5Isi8fDnaspMVwKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggPydIPAEoHZLPOORyiuO89dapDqzlkaEF2jwgKsR/n1iiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg6oCpT/k8ZygQrZYyB2oCzsN2LdGHk09d9cPE7wBNzON0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQFqQEE2QzhWq2JYx8NKrUNYblXolC6OhE/ff0Lqdmektdoz6ZZkqTANm4/NSkVaNTw+Y+6t84dmf6Bep1RnyQug= \ No newline at end of file diff --git a/src/instrumentserver/resource/icons/unlock.svg b/src/instrumentserver/resource/icons/unlock.svg new file mode 100644 index 0000000..cac7f69 --- /dev/null +++ b/src/instrumentserver/resource/icons/unlock.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo5YzY4N2ZkMy1hNWY1LTQxMzQtOTMyMS0xMDgxZGExNjNkNTcAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaIY5h/ZTVYVZV1Ao3LYJzbcAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDplZjlmYzJhZi04MTI0LTQ4OGItYmZjNy01MGYwMTUxNDVkNzhscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNokypG5rVB+pnNxNzNRVFPLAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggIdZ9yJkU6/kniuRFoNsdT7K2u/WIKDDcQZCXwO1R3dCkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaIRzy/fWK4uT8d72zIojvFMAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCC9EbQ54sPbtZgM7Xznv8gAULbt8i20YqEXolFpepwYlGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBj0Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjljNjg3ZmQzLWE1ZjUtNDEzNC05MzIxLTEwODFkYTE2M2Q1Ny9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmM1ODViYTRkLTJhYzktNDA1My1hZmIzLWI1YjZjNmNjNjU4N3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCAh1n3ImRTr+SeK5EWg2x1Psra79YgoMNxBkJfA7VHd0KJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggJ4O6CcHERdMUD/kEtQvp91yTD3DOR5nBw3ecuJchKRmiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggk1NOJN5080WBNcCRUWwWQvJUFcTY8CINurdTDSrlkol0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQL18gbkgalqKQ7O0PKcTEoIlmvbtsmrjn59oC+aPm4eNBl6iWYPYQvr76nKMBkTraSav0j8ugTSu4m4GubbosOc= \ No newline at end of file diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 47cd2c5..6310d40 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -1,5 +1,6 @@ """Client-side state and Broadcast handling of the Parameter Manager GUI -(plan task 5.1), plus its tabs, tints and gutter bands (plan task 5.2). +(plan task 5.1), its tabs, tints and gutter bands (plan task 5.2), and its +Lock column, lock toggle, context menu and arm strip (plan task 5.3). The GUI keeps the Parameter Manager's Types and Locks in a ``PMState`` (``ParameterManagerGui.state``), filled from the Parameter Manager on @@ -10,7 +11,10 @@ ``qtbot.waitUntil``. The 5.2 tests cover the tab widget around the existing view, the pure ``compute_claims`` function and the tint palette without a Server, and the tints and gutter bands a second Client's Type -edits produce live. +edits produce live. The 5.3 tests cover the pure Lock helpers, the arm +strip and the read-only rendering without a Server, and the arm-via-context-menu +flow, the toggle, the refused cycle, the Follower repaint on a second +Client's Target update, and the model reload path live. Two shapes of the live path are deliberately avoided in these tests, both pre-existing and outside this task's scope: @@ -40,17 +44,24 @@ GUTTER_COLUMN, GUTTER_ROLE, GUTTER_WIDTH, + LOCK_COLUMN, TINT_COLOURS, Claim, GutterDelegate, ItemParameters, + LockArmStrip, ModelParameters, ParameterManagerGui, ParameterManagerTreeView, PMState, TypePalette, compute_claims, + followers_reaching, + lock_column_text, + rank_lock_targets, + relative_path, ) +from instrumentserver.gui.parameters import ParameterWidget PM_NAME = "parameter_manager" PM_CLASS = "instrumentserver.params.ParameterManager" @@ -692,7 +703,8 @@ def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): def _row_items(gui, path): - """The four items of the row ``path``: name, unit, delegate, gutter.""" + """The five items of the row ``path``: name, unit, delegate, gutter and + Lock column.""" matches = gui.model.findItems( path, QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, @@ -702,8 +714,13 @@ def _row_items(gui, path): item = matches[0] parent = item.parent() if parent is None: - return [gui.model.item(item.row(), column) for column in range(4)] - return [parent.child(item.row(), column) for column in range(4)] + return [gui.model.item(item.row(), column) for column in range(5)] + return [parent.child(item.row(), column) for column in range(5)] + + +def _lock_item(gui, path): + """The Lock column item of the row ``path``.""" + return _row_items(gui, path)[LOCK_COLUMN] def _type_tint(gui, type_name): @@ -895,3 +912,427 @@ def _q01_bw_gone_and_q01_untinted(): assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None finally: gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.3: Lock column, toggle, context menu, arm strip +# --------------------------------------------------------------------------- + + +def test_relative_path_strips_the_instrument_name(): + """Lock Targets are stored as full dotted paths; every string the GUI + shows is relative to the Parameter Manager.""" + assert relative_path(f"{PM_NAME}.q01.IF", PM_NAME) == "q01.IF" + # a path without the prefix is returned unchanged + assert relative_path("q01.IF", PM_NAME) == "q01.IF" + + +def test_lock_column_text_shows_the_three_forms(): + """A Follower shows ``locked to `` or ``unlocked · `` + (relative Target, middle dot); a Target of N Locks — locked and + unlocked alike — shows ``target ×N``; everything else shows nothing.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + } + assert lock_column_text("q02.IF", locks, PM_NAME) == "locked to q01.IF" + assert lock_column_text("q03.IF", locks, PM_NAME) == "unlocked · q01.IF" + # the Target note counts unlocked Locks too + assert lock_column_text("q01.IF", locks, PM_NAME) == "target ×2" + assert lock_column_text("other.x", locks, PM_NAME) == "" + assert lock_column_text("q01", locks, PM_NAME) == "" + + +def test_lock_column_text_follower_text_wins_over_the_target_note(): + """A row that is both Follower and Target shows its own Lock state, + like the mock's ``rec.lockedTo || srcNote(p)``.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q01.IF": PMLockBluePrint(target=f"{PM_NAME}.other.x", locked=False), + } + assert lock_column_text("q01.IF", locks, PM_NAME) == "unlocked · other.x" + + +def test_followers_reaching_walks_locked_chains(): + """Every Follower whose locked Lock targets the parameter directly or + over a chain of locked Locks; the direct Follower and the middle hop + both reach ``q01.IF``.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + } + assert sorted(followers_reaching("q01.IF", locks, PM_NAME)) == [ + "q02.IF", + "q03.IF", + ] + assert followers_reaching("q02.IF", locks, PM_NAME) == ["q03.IF"] + assert followers_reaching("q03.IF", locks, PM_NAME) == [] + + +def test_an_unlocked_middle_hop_stops_the_chain(): + """An unlocked Lock answers ``get`` with its own value (D7), so the + Followers behind it do not see an update made past it.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + } + assert followers_reaching("q01.IF", locks, PM_NAME) == [] + assert followers_reaching("q02.IF", locks, PM_NAME) == ["q03.IF"] + + +def test_a_cycle_in_the_locks_does_not_loop(): + """A Lock mapping that holds a cycle terminates the walk.""" + locks = { + "q01.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + } + assert sorted(followers_reaching("q01.IF", locks, PM_NAME)) == [ + "q01.IF", + "q02.IF", + ] + + +def test_rank_lock_targets_orders_like_the_mock(): + """Rank 0 (same relative path inside its Instance) before rank 1 (the + relative path occurs as a submodule) before rank 2, alphabetical + within a rank, the Follower itself excluded.""" + claims = { + "q01.IF": Claim(type="qubit", instance="q01", stack=["qubit"]), + "q02.IF": Claim(type="qubit", instance="q02", stack=["qubit"]), + } + candidates = ["q01.IF", "q05.IF.gain", "other.x", "other.a", "q02.IF"] + ranked = rank_lock_targets("q01.IF", candidates, claims) + assert ranked == ["q02.IF", "q05.IF.gain", "other.a", "other.x"] + + +def test_rank_lock_targets_without_a_claim_is_alphabetical(): + """A Follower claimed by no Type has no relative path to prefer, so + every candidate is rank 2 and sorts alphabetically.""" + ranked = rank_lock_targets( + "q01.IF", ["zz.x", "aa.x", "q01.IF"], {} + ) + assert ranked == ["aa.x", "zz.x"] + + +def test_lock_arm_strip_picks_cancels_and_shows_errors(qtbot): + """The arm strip picks with Return (the exact path, or the first + ranked candidate when the text is not a path), cancels with Escape and + the Cancel button, shows the error text, and disarms.""" + strip = LockArmStrip() + qtbot.addWidget(strip) + picked = [] + strip.targetPicked.connect(picked.append) + cancelled = [] + strip.cancelled.connect(lambda: cancelled.append(True)) + + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + assert strip.label.text() == "Target for q01.IF" + assert strip.completerModel.stringList() == ["q02.IF", "q03.IF"] + assert not strip.isHidden() + + strip.lineEdit.setText("q02.IF") + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) + assert picked == ["q02.IF"] + + # a text that is no path picks the first ranked candidate + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + strip.lineEdit.setText("garbage") + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) + assert picked == ["q02.IF", "q02.IF"] + + # the error label shows the Server's text until the next disarm + strip.show_error("cycle: cannot lock q02.IF to q01.IF") + assert strip.errorLabel.text() == "cycle: cannot lock q02.IF to q01.IF" + assert not strip.errorLabel.isHidden() + + # Escape cancels while the strip has focus; the strip only emits the + # signal — hiding on cancel is the GUI's cancel_arm + strip.show() + qtbot.waitExposed(strip) + strip.activateWindow() + strip.lineEdit.setFocus() + qtbot.wait(20) + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Escape) + assert cancelled == [True] + + # so does the Cancel button, and disarming hides and clears the strip + strip.arm("q01.IF", ["q02.IF"]) + strip.show_error("cycle: cannot lock q02.IF to q01.IF") + strip.cancelButton.click() + assert cancelled == [True, True] + strip.disarm() + assert strip.isHidden() + assert strip.errorLabel.isHidden() and strip.errorLabel.text() == "" + + +def test_parameter_widget_set_read_only(qtbot): + """set_read_only disables the input and the set button and keeps the + get button enabled, and stores the flag.""" + from instrumentserver.params import ParameterManager + + manager = ParameterManager("pw_read_only_local") + manager.add_parameter("x", initial_value=1.0) + widget = ParameterWidget(manager.parameter("x")) + qtbot.addWidget(widget) + + assert widget.read_only is False + widget.set_read_only(True) + assert widget.read_only is True + assert not widget.paramWidget.isEnabled() + assert not widget.setButton.isEnabled() + assert widget.getButton.isEnabled() + + widget.set_read_only(False) + assert widget.read_only is False + assert widget.paramWidget.isEnabled() + assert widget.setButton.isEnabled() + assert widget.getButton.isEnabled() + + +def _make_live_parameters(pm): + """Create the 5.3 live tests' parameters before the GUI is built (a + parameter another Client creates while the GUI is open crashes the + model's parameter-creation branch, TEST_AUDIT.md).""" + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q02.IF", initial_value=2.0, unit="Hz") + pm.add_parameter("q03.IF", initial_value=3.0, unit="Hz") + pm.add_parameter("other.x", initial_value=4.0, unit="s") + pm.update() # the GUI's tree is built from the proxy's blueprint + + +def _click_row(qtbot, gui, path): + """Click the tree row ``path`` with the left mouse button; the GUI must + be shown for the view to have geometry.""" + gui.show() + qtbot.waitExposed(gui) + gui.view.expandAll() + source_index = gui.model.indexFromItem(_row_items(gui, path)[0]) + proxy_index = gui.proxyModel.mapFromSource(source_index) + qtbot.mouseClick( + gui.view.viewport(), + QtCore.Qt.MouseButton.LeftButton, + pos=gui.view.visualRect(proxy_index).center(), + ) + + +def test_arm_via_context_menu_pick_a_row_and_toggle( + qtbot, pm, second_client, server_port +): + """The plan's named flow: arm through the context menu, pick a row to + lock, watch the Lock column and the lock button repaint through the + Broadcast, toggle with the lock button both ways, and unlock through + the context menu.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + gui.view.lastSelectedItem = _row_items(gui, "q01.IF")[0] + gui.view.lockToAction.trigger() + + assert not gui.armStrip.isHidden() + assert gui.armStrip.label.text() == "Target for q01.IF" + assert gui.armed_follower == "q01.IF" + candidates = gui.armStrip.completerModel.stringList() + assert "q02.IF" in candidates + assert "q01.IF" not in candidates + + # clicking the q02.IF row picks it as the Target + _click_row(qtbot, gui, "q02.IF") + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.armStrip.isHidden() + assert gui.armed_follower is None + + # the pm-lock-update Broadcast repaints the Lock column and the + # lock button, and renders the locked Follower read-only + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + assert _lock_item(gui, "q02.IF").text() == "target ×1" + + follower_widget = gui.view.delegate.parameters["q01.IF"] + button = follower_widget.lockButton + assert not button.isHidden() + assert button.property("locked") is True + assert not follower_widget.paramWidget.isEnabled() + + # toggle with the lock button: unlock first … + button.click() + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "unlocked · q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + assert button.property("locked") is False + assert follower_widget.paramWidget.isEnabled() + + # … and lock again to the remembered Target + button.click() + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is True, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + assert button.property("locked") is True + + # the context menu's Unlock unlocks on the Server + gui.view.lastSelectedItem = _row_items(gui, "q01.IF")[0] + gui.view.unlockAction.trigger() + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "unlocked · q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_cycle_attempt_shows_the_error_and_stays_armed( + qtbot, pm, second_client, server_port +): + """Locking the Follower's own Target back would close a cycle (D7): + the Server refuses, the arm strip shows the error text and stays + armed, and Escape disarms it.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + + gui.arm_lock("q02.IF") + assert gui.armed_follower == "q02.IF" + assert not gui.armStrip.isHidden() + + gui.pick_lock_target("q01.IF") + assert "cycle" in gui.armStrip.errorLabel.text() + assert not gui.armStrip.isHidden() + assert gui.armed_follower == "q02.IF" + assert pm.get_lock("q02.IF") is None + + # Escape disarms the pick + gui.show() + qtbot.waitExposed(gui) + gui.armStrip.activateWindow() + gui.armStrip.lineEdit.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.armStrip.lineEdit, QtCore.Qt.Key.Key_Escape) + assert gui.armStrip.isHidden() + assert gui.armed_follower is None + finally: + gui.model.stopListener() + + +def test_setting_the_target_from_a_second_client_repaints_the_followers( + qtbot, pm, second_client, server_port +): + """A value the second Client sets on a Target repaints every Follower + whose locked Lock chain reaches it (D3: a locked Follower answers get + with the Target's value, and nothing is ever pushed into it).""" + _make_live_parameters(pm) + # the second Client's proxy is built after the parameters exist, so its + # blueprint knows q02.IF + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # a chain: q03.IF follows q01.IF, which follows q02.IF + pm.lock("q01.IF", "q02.IF") + pm.lock("q03.IF", "q01.IF") + qtbot.waitUntil( + lambda: gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + and gui.state.locks.get("q03.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.q02.IF.set(7) + follower_widget = gui.view.delegate.parameters["q01.IF"] + chain_widget = gui.view.delegate.parameters["q03.IF"] + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 7, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: chain_widget._getMethod() == 7, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_second_clients_lock_shows_in_the_column_and_button( + qtbot, pm, second_client, server_port +): + """A Lock the second Client makes shows up in the Lock column and on + the lock button without any GUI action.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + assert _lock_item(gui, "q03.IF").text() == "" + + second_pm.lock("q03.IF", "q02.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q03.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + widget = gui.view.delegate.parameters["q03.IF"] + assert not widget.lockButton.isHidden() + assert widget.lockButton.property("locked") is True + assert not widget.paramWidget.isEnabled() + finally: + gui.model.stopListener() + + +def test_refresh_all_shows_a_lock_made_while_the_listener_was_stopped( + qtbot, pm, second_client, server_port +): + """With the listener stopped, a Lock the second Client makes still + reaches the Lock column and the lock button through refreshAll's + re-read of the state.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + second_pm.lock("q03.IF", "q02.IF") + assert _lock_item(gui, "q03.IF").text() == "" + + gui.refreshAll() + assert _lock_item(gui, "q03.IF").text() == "locked to q02.IF" + widget = gui.view.delegate.parameters["q03.IF"] + assert not widget.lockButton.isHidden() + assert widget.lockButton.property("locked") is True + assert not widget.paramWidget.isEnabled() + finally: + gui.model.stopListener() From 5e83eadda549de94048e22f3c2fb18badd2f2145 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 11:57:47 -0500 Subject: [PATCH 075/107] 5.3: fix from review round 1: filter re-apply, filtered-completer Return, snake_case renames, and the round-0 test gaps --- TEST_AUDIT.md | 1 + src/instrumentserver/gui/instruments.py | 42 ++++--- test/pytest/test_pm_gui.py | 145 +++++++++++++++++++++++- 3 files changed, 170 insertions(+), 18 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 3d31275..72d8ccf 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -44,6 +44,7 @@ States: | user_guide/parameter_manager.md (future) | Types — empty Type name | `add_type("")` succeeds (only `_globals` is refused, task 2.1), so a manager can hold an empty-named Type that `toFile` writes and the version-2 reader refuses; such a manager cannot round-trip | Found during the plan 4.2 review (test-reviewer-glm) | gap | Pre-existing since 2.1; not changed per plan rule 6; fix is an empty-name refusal in `add_type` plus a test | | gui_features.md (future) | Parameter Manager GUI — live creation from another client | `ModelParameters.updateParameter`'s `parameter-creation` branch calls `instrument.update()` and then `nestedAttributeFromString` on the Proxy Instrument; a parameter another Client creates while the GUI is open raises `AttributeError` there (stale Proxy blueprint), so the row never appears | Found during the plan 5.1 work (coder probe, verified pre-existing by all six reviewers) | gap | Pre-existing; not changed per plan rule 6; to be looked at with the 5.x live-update work or in 6.3 | | user_guide/parameter_manager.md (future) | Profiles — GUI start with no profile file | `ParameterManagerGui.__init__` calls `loadProfile`, which calls `switch_to_profile` with the combo's current text; with no profile file present `switch_to_profile` raises, so the GUI cannot be built until one profile exists | Found during the plan 5.1 work (coder probe) | gap | Pre-existing; not changed per plan rule 6 | +| gui_features.md (future) | Parameter Manager GUI — `parameter-update` for a row with no widget | `ParameterManagerTreeView.onItemNewValue` indexes `self.delegate.parameters[itemName]` without a guard, so a `parameter-update` Broadcast for a row whose editor widget was never created raises `KeyError` and the value never shows | Found during the plan 5.3 round-0 review (reviewer-qwen) | gap | Pre-existing; not changed per plan rule 6; the new `ParameterManagerGui._on_item_new_value` guards with `.get` and logs instead of raising | ## Manual checks diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 78cac8b..9ca6364 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1258,19 +1258,25 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: @QtCore.Slot() def _on_return_pressed(self) -> None: - """Pick the exact typed path, or the first ranked candidate when - the typed text is not a path itself (the mock's Enter picks the - first match).""" + """Pick the exact typed path, or the first completion the + completer filters for the typed text (the mock's Enter picks the + first match); a text that matches no candidate picks nothing.""" text = self.lineEdit.text().strip() if not text: return - candidates = self.completerModel.stringList() - if text in candidates: - self.targetPicked.emit(text) - elif candidates: - self.targetPicked.emit(candidates[0]) - else: + if text in self.completerModel.stringList(): self.targetPicked.emit(text) + return + # the completer's filtered matches for what was typed, in ranked + # order; its filter mode (MatchContains) and case sensitivity apply + self.completer.setCompletionPrefix(text) + if self.completer.completionCount() > 0: + first = self.completer.completionModel().index(0, 0) + self.targetPicked.emit( + self.completer.completionModel().data( + first, QtCore.Qt.ItemDataRole.DisplayRole + ) + ) def arm(self, follower: str, candidates: List[str]) -> None: """Arm the strip for the Follower at ``follower``: name it in the @@ -1327,7 +1333,7 @@ def createEditor( # type: ignore[override] element = item.element # type: ignore[attr-defined] rw = self.makeRemoveWidget(item.name, widget) # type: ignore[attr-defined] - lw = self.makeLockWidget(item.name, widget) + lw = self.make_lock_widget(item.name, widget) ret = ParameterWidget( parameter=element, parent=widget, additionalWidgets=[lw, rw] @@ -1348,7 +1354,7 @@ def createEditor( # type: ignore[override] return ret - def makeLockWidget( + def make_lock_widget( self, fullName: str, widget: QtWidgets.QWidget ) -> QtWidgets.QPushButton: """The per-row lock button. It stays hidden until the row carries a @@ -1437,15 +1443,15 @@ def __init__( # before the menu opens); the Parameter Manager GUI enables and # disables them in its aboutToShow slot self.lockToAction = QtWidgets.QAction("Lock to…") - self.lockToAction.triggered.connect(self.onLockToActionTrigger) + self.lockToAction.triggered.connect(self._on_lock_to_action_trigger) self.unlockAction = QtWidgets.QAction("Unlock") - self.unlockAction.triggered.connect(self.onUnlockActionTrigger) + self.unlockAction.triggered.connect(self._on_unlock_action_trigger) self.contextMenu.addSeparator() self.contextMenu.addAction(self.lockToAction) self.contextMenu.addAction(self.unlockAction) @QtCore.Slot() - def onLockToActionTrigger(self) -> None: + def _on_lock_to_action_trigger(self) -> None: """The context menu's "Lock to…": arm the target picker for the row's parameter; a submodule row has no Lock to arm.""" item = self.lastSelectedItem @@ -1453,7 +1459,7 @@ def onLockToActionTrigger(self) -> None: self.lockToRequested.emit(item.name) @QtCore.Slot() - def onUnlockActionTrigger(self) -> None: + def _on_unlock_action_trigger(self) -> None: """The context menu's "Unlock": unlock the row's Lock; a submodule row has no Lock to unlock.""" item = self.lastSelectedItem @@ -1650,6 +1656,12 @@ def connectSignals(self) -> None: self.model.structureChanged.connect(self.apply_tints) self.model.structureChanged.connect(self.apply_locks) self.model.itemNewValue.connect(self._on_item_new_value) + # the filter (and the trash toggle) hides rows; when they come + # back, restoreCollapsedDict has re-opened their persistent + # editors, so createEditor has built fresh ParameterWidgets whose + # lock button is hidden and whose input is editable — re-apply the + # Lock state to them + self.proxyModel.filterFinished.connect(self.apply_locks) self.view.lockToRequested.connect(self.arm_lock) self.view.unlockRequested.connect(self._unlock) self.view.contextMenu.aboutToShow.connect(self._update_lock_actions) diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 6310d40..98ce824 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -45,6 +45,7 @@ GUTTER_ROLE, GUTTER_WIDTH, LOCK_COLUMN, + LOCK_COLUMN_WIDTH, TINT_COLOURS, Claim, GutterDelegate, @@ -698,6 +699,13 @@ def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): ) assert gui.view.gutterDelegate.typePalette is gui.typePalette assert gui.view.treePosition() == 0 + # the Lock column sits between the unit and the delegate column + # (visual order: gutter, name, unit, locked to, delegate), with a + # resizable default width and its header label + assert header.visualIndex(LOCK_COLUMN) == 3 + assert header.visualIndex(2) == 4 + assert header.sectionSize(LOCK_COLUMN) == LOCK_COLUMN_WIDTH + assert gui.model.horizontalHeaderItem(LOCK_COLUMN).text() == "locked to" finally: gui.model.stopListener() @@ -1034,11 +1042,22 @@ def test_lock_arm_strip_picks_cancels_and_shows_errors(qtbot): qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) assert picked == ["q02.IF"] - # a text that is no path picks the first ranked candidate + # the completer popup's pick path (the activated signal) emits too + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + strip.completer.activated[str].emit("q03.IF") + assert picked == ["q02.IF", "q03.IF"] + + # a text that matches no candidate picks nothing (no unrelated Target) strip.arm("q01.IF", ["q02.IF", "q03.IF"]) strip.lineEdit.setText("garbage") qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) - assert picked == ["q02.IF", "q02.IF"] + assert picked == ["q02.IF", "q03.IF"] + + # a partial text that matches picks the first filtered completion + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + strip.lineEdit.setText("q03") + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) + assert picked == ["q02.IF", "q03.IF", "q03.IF"] # the error label shows the Server's text until the next disarm strip.show_error("cycle: cannot lock q02.IF to q01.IF") @@ -1163,6 +1182,13 @@ def test_arm_via_context_menu_pick_a_row_and_toggle( assert button.property("locked") is True assert not follower_widget.paramWidget.isEnabled() + # locking repaints the Follower's value: while locked it answers + # get with the Target's value (D3) + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 2.0, + timeout=BROADCAST_TIMEOUT, + ) + # toggle with the lock button: unlock first … button.click() qtbot.waitUntil( @@ -1176,6 +1202,12 @@ def test_arm_via_context_menu_pick_a_row_and_toggle( assert button.property("locked") is False assert follower_widget.paramWidget.isEnabled() + # unlocking exposes the Follower's own value again (D3, D5) + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 1.0, + timeout=BROADCAST_TIMEOUT, + ) + # … and lock again to the remembered Target button.click() qtbot.waitUntil( @@ -1187,6 +1219,10 @@ def test_arm_via_context_menu_pick_a_row_and_toggle( timeout=BROADCAST_TIMEOUT, ) assert button.property("locked") is True + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 2.0, + timeout=BROADCAST_TIMEOUT, + ) # the context menu's Unlock unlocks on the Server gui.view.lastSelectedItem = _row_items(gui, "q01.IF")[0] @@ -1233,9 +1269,19 @@ def test_a_cycle_attempt_shows_the_error_and_stays_armed( assert gui.armed_follower == "q02.IF" assert pm.get_lock("q02.IF") is None - # Escape disarms the pick + # Escape over the tree disarms the pick too (the view's Escape + # shortcut calls cancel_arm) gui.show() qtbot.waitExposed(gui) + gui.view.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_Escape) + assert gui.armStrip.isHidden() + assert gui.armed_follower is None + + # re-arm: Escape in the strip's line edit disarms as well + gui.arm_lock("q02.IF") + assert gui.armed_follower == "q02.IF" gui.armStrip.activateWindow() gui.armStrip.lineEdit.setFocus() qtbot.wait(20) @@ -1313,6 +1359,51 @@ def test_a_second_clients_lock_shows_in_the_column_and_button( gui.model.stopListener() +def test_the_context_menu_lock_actions_enable_by_the_lock_state( + qtbot, pm, second_client, server_port +): + """The aboutToShow rule: "Lock to…" is enabled for every parameter + row, "Unlock" only while that row's Lock in the state is locked, and + both are disabled on a submodule row.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q03.IF", "q02.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q03.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + + # (a) a parameter row with a locked Lock: both enabled + gui.view.lastSelectedItem = _row_items(gui, "q03.IF")[0] + gui.view.contextMenu.aboutToShow.emit() + assert gui.view.lockToAction.isEnabled() + assert gui.view.unlockAction.isEnabled() + + # (b) the same row after the second Client unlocks: only "Lock + # to…" stays enabled + second_pm.unlock("q03.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q03.IF").text() == "unlocked · q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + gui.view.contextMenu.aboutToShow.emit() + assert gui.view.lockToAction.isEnabled() + assert not gui.view.unlockAction.isEnabled() + + # (c) a submodule row: both disabled + gui.view.lastSelectedItem = _row_items(gui, "q03")[0] + gui.view.contextMenu.aboutToShow.emit() + assert not gui.view.lockToAction.isEnabled() + assert not gui.view.unlockAction.isEnabled() + finally: + gui.model.stopListener() + + def test_refresh_all_shows_a_lock_made_while_the_listener_was_stopped( qtbot, pm, second_client, server_port ): @@ -1336,3 +1427,51 @@ def test_refresh_all_shows_a_lock_made_while_the_listener_was_stopped( assert not widget.paramWidget.isEnabled() finally: gui.model.stopListener() + + +def test_a_filter_cycle_re_applies_the_lock_state( + qtbot, pm, second_client, server_port +): + """A filter cycle re-opens the hidden rows' persistent editors as + fresh ParameterWidgets; apply_locks runs on filterFinished, so a + locked Follower comes back with its lock button and read-only input.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + + def _q01_if_is_mapped(mapped: bool): + matches = gui.model.findItems( + "q01.IF", + QtCore.Qt.MatchFlag.MatchExactly + | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + proxy_index = gui.proxyModel.mapFromSource( + gui.model.indexFromItem(matches[0]) + ) + return proxy_index.isValid() is mapped + + # the filter hides the q01.IF row … + gui.lineEdit.setText("other") + qtbot.waitUntil(lambda: _q01_if_is_mapped(False), timeout=BROADCAST_TIMEOUT) + + # … and clearing it brings the row back with its Lock state + gui.lineEdit.setText("") + widget = gui.view.delegate.parameters["q01.IF"] + qtbot.waitUntil( + lambda: widget.lockButton.property("locked") is True + and not widget.lockButton.isHidden(), + timeout=BROADCAST_TIMEOUT, + ) + assert not widget.paramWidget.isEnabled() + finally: + gui.model.stopListener() From 113e482c4461ccf8700dcd9130b51734cc495c1e Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 12:15:12 -0500 Subject: [PATCH 076/107] 5.3: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 50 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index da68e4e..1b42b3a 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -797,3 +797,53 @@ The Parameter Manager GUI now keeps a client-side copy of the Types and Locks. ` - reviewer-qwen's inline Qt heredoc, which came through truncated in the prompt - a mistyped path outside the repository from test-reviewer-qwen - The orchestrator session stopped in fix round 1, after the 2026-09-25 implementation commit, while the coder was waiting on a permission prompt. It resumed on 2026-09-28, and the fix commit and re-reviews followed that day. + +## 5.3 Lock column, toggle, context menu, arm strip — 2026-09-28 + +`ModelParameterManager` gains a fifth logical column, `LOCK_COLUMN = 4` ("locked to"), shown between the unit and the delegate column. Its text comes from the pure function `lock_column_text` over `PMState.locks`: `locked to `, `unlocked · ` or `target ×N`. Each row's delegate widget has a `lockButton`, visible when the row has a Lock and filled with `LOCK_COLOUR` while locked, which calls `toggle_lock`. The context menu gets "Lock to…" and "Unlock". The new `LockArmStrip` sits under the toolbar and offers the Targets ranked by `rank_lock_targets`, and `ParameterManagerGui.pick_lock_target` shows the Server's error text in it. `ParameterWidget.set_read_only` renders a locked Follower read-only, and `followers_reaching` finds the rows to repaint on a `parameter-update`. `test_pm_gui.py` grew from 25 to 42 tests. + +### Commit by commit +- `c215c5c` The column, button, menu entries, arm strip, repaint and 15 tests. The orchestrator's coder spec set sixteen readings. The main ones: + - Every displayed or compared Target goes through `relative_path`, because `PMLockBluePrint.target` is the full path while `PMState.locks` keys are relative. The Follower text wins over the `target ×N` note, as the mock's `rec.lockedTo || srcNote(p)` does. `target ×N` counts unlocked Locks too, as `followers_of` does. + - The Lock column is added without renumbering columns 0–3, and `moveSection` puts it at visual index 3. The setup is guarded on `columnCount()` for 5.1's 3-column D24 stub test. `_ensure_gutter_items` became `_ensure_extra_items`, and the tints now cover all five columns. + - `apply_locks()` is the only writer of the column text, the button's visibility, tooltip and `locked` property, and the read-only flag. It runs after `state.refresh` in `refreshAll`/`loadProfile`, on `structureChanged`, and from the new `_on_lock_changed`, which then repaints the Follower and every row that `followers_reaching` returns. + - `followers_reaching` follows locked hops only (D7), de-duplicates, and stops on a cycle. `_on_item_new_value` uses it to refresh Follower rows with `setWidgetFromParameter`. The base `onItemNewValue` wiring is unchanged. + - Cycles are not filtered client-side. The Server's refusal is shown instead. + - The plan's Design reference says lock.svg/unlock.svg are already in `resource/icons/`. They were not, so the coder copied them from the mock's assets, added them to `resource.qrc` and regenerated `resource.py` with pyrcc5. reviewer-glm found it byte-identical to a fresh run. + + The coder's own interpretations: `LOCK_COLOUR = "#7e5bef"`, the mock's `--log-value` token, taken from its `colors.css` (the spec's fallback `#7b3fa0` was not used). `set_read_only` skips the no-set `QLabel` case so it never re-enables a set button that construction disabled. The menu actions are enabled in an `aboutToShow` slot. `unlock.svg` is registered but unused. The spec said a server-side `ValueError` comes back as the same type. It actually arrives as a generic `Exception`, so `pick_lock_target` catches `Exception`. On Return with a text that matched no candidate, the strip picked `candidates[0]`. The fix commit replaced that. + + The tests: eight no-server tests of `relative_path`, `lock_column_text`, `followers_reaching` (chain, unlocked middle hop, cycle) and `rank_lock_targets`; `test_lock_arm_strip_picks_cancels_and_shows_errors`; `test_parameter_widget_set_read_only`; and five live tests. Those are `test_arm_via_context_menu_pick_a_row_and_toggle` (the plan's named flow: arm via the menu, click the `q02.IF` row, `get_lock` on the Server, toggle both ways, menu Unlock), `test_a_cycle_attempt_shows_the_error_and_stays_armed`, `test_setting_the_target_from_a_second_client_repaints_the_followers` (with the chained `q03.IF`), `test_a_second_clients_lock_shows_in_the_column_and_button` and `test_refresh_all_shows_a_lock_made_while_the_listener_was_stopped`. All parameters are created before the GUI, to avoid the 5.1 `parameter-creation` crash. Orchestrator run: ruff clean, 50 in the three named GUI files, 506 in the full suite. +- `5e83ead` Fix from round 0, nine items: + - `self.proxyModel.filterFinished.connect(self.apply_locks)`. A filter cycle, or the trash toggle, makes `restoreCollapsedDict` reopen the persistent editors as fresh `ParameterWidget`s, so a locked Follower came back with its button hidden and its input editable. reviewer-glm found it with a Qt probe, and the orchestrator confirmed it in `base_instrument.py`. New test: `test_a_filter_cycle_re_applies_the_lock_state`. + - `LockArmStrip._on_return_pressed` now ports the mock's `armKeyDown`. An exact candidate is picked; otherwise the completer's first filtered match (`setCompletionPrefix`, then row 0 of `completionModel()`); with no match, nothing. The old code locked "garbage" to the first ranked candidate. reviewer-qwen raised it as should-fix and reviewer-glm as a nit. The orchestrator ruled that reading 10's "first completion" meant the filtered list. The strip test now checks `"garbage"` → nothing and `"q03"` → `q03.IF`. + - Plan rule 8: `makeLockWidget`, `onLockToActionTrigger`, `onUnlockActionTrigger` → `make_lock_widget`, `_on_lock_to_action_trigger`, `_on_unlock_action_trigger`. plan-checker-glm raised it as must-fix, reviewer-glm as should-fix and plan-checker-qwen as a nit. The pre-existing `makeRemoveWidget`/`onStarActionTrigger` are grandfathered. + - Test gaps raised by the test reviewers: + - the `lockChanged` value repaint: `_getMethod()` goes 2.0 → 1.0 → 2.0 across lock, unlock and relock in the named flow (both test reviewers, should-fix) + - the Lock column's visual index, width (`LOCK_COLUMN_WIDTH`) and header text in the tab test (test-reviewer-qwen should-fix, test-reviewer-glm nit) + - Escape pressed on the tree, not only in the line edit (test-reviewer-qwen) + - the completer's `activated` path (both) + - the `aboutToShow` enabling rule, in the new `test_the_context_menu_lock_actions_enable_by_the_lock_state` (both) + - A `gap` row in `TEST_AUDIT.md` (see Loose ends). + + All six approved in re-review, and every raiser confirmed their item fixed. test-reviewer-qwen checked that the new `filterFinished` connection runs after the base class's `restoreCollapsedDict` one. Orchestrator run: ruff clean, 52 in the three named GUI files, 508 in the full suite. + +### Dropped findings +- The two copied SVGs each carry about 8 KB of C2PA/JUMBF provenance metadata (reviewer-glm, nit) → not sent. It does not change behaviour, and stripping it means regenerating `resource.py`. It is still in both files. +- No test pins the error label's colour to "the existing alert colour" (plan-checker-qwen). The reading was under-specified; reviewer-glm checked that the `red` used matches the design system's `#ff0000` token. +- Round-0 test nits, not sent: the lock button tooltips are unasserted, the diamond de-dup case in `followers_reaching`, and clicking a submodule row while armed. +- Round-1 nits, none sent: the empty-text early return in `_on_return_pressed` is untested (test-reviewer-glm). The `LockArmStrip` class docstring and the strip test's docstring still describe the old Return rule, and the module docstring omits the two new live tests (plan-checker-qwen, test-reviewer-qwen). + +### Loose ends +- `TEST_AUDIT.md`, "Parameter Manager GUI — `parameter-update` for a row with no widget": the pre-existing `ParameterManagerTreeView.onItemNewValue` indexes `self.delegate.parameters[itemName]` without a guard and raises `KeyError`. The new `_on_item_new_value` uses `.get` (reviewer-qwen). +- The stale Return docstrings above are still there. The orchestrator suggested that a later task touching `LockArmStrip` refresh them. +- The icons: `decisions.md` flags for Marcos that the plan's Design reference was wrong about lock.svg/unlock.svg. 5.6's qrc check now only has to verify them. No answer from Marcos is recorded. +- The convention from 5.2 was extended: the `on…Trigger` slot family gets no exemption from snake_case in 5.4–5.6. +- 5.2's loose end, where a `parameter-update` for an unknown row adds an untinted row, is untouched. + +### Process notes +- The coder sat idle for about 20 minutes after its read pass, with no edits and no message. One nudge in the terminal got it going again, the same pattern as in 2.3. +- The first test-reviewer-glm terminal had no opencode on its PATH, so the dispatch failed with `agent_unconfigured`. The orchestrator closed it and started a new terminal. +- The orchestrator blanked its own list of reviewer handles with a bad shell pipeline. Five reviewers waited on permission prompts for about 10 minutes until the orchestrator swept them. +- Five permission requests were rejected, all inline Python probes that came through truncated in the prompt: one from the coder, and one each from reviewer-glm, reviewer-qwen, plan-checker-glm and plan-checker-qwen. Each reran its probe as a scratch file under `orchestration/5.3/`. +- The watcher's mkdir-prefix rule let the coder's icon `cp` through automatically. The orchestrator would have allowed it anyway, and it tightened the rule afterwards. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 0170aa0..edd9f79 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -567,7 +567,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session reload. Tests: `test_pm_gui.py` — after `add_type` + entries from a second client, rows under a matching submodule carry the Type's tint; after `remove_type_parameter`, the parameter's row loses it. -- [ ] **5.3 Lock column, toggle, context menu, arm strip.** New model column "locked to" +- [x] **5.3 Lock column, toggle, context menu, arm strip.** New model column "locked to" (text: `locked to ` / `unlocked · ` / `target ×N` from `followers_of` computed client-side over `PMState.locks`). Per-row lock button in the delegate widget (visible when a Lock exists; purple fill while locked) calling `toggle_lock`. Context menu From 74d2d9f2a781d4de74540bc07c94aca8835ff7ad Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 12:51:09 -0500 Subject: [PATCH 077/107] =?UTF-8?q?5.4:=20Locks=20panel=20=E2=80=94=20spli?= =?UTF-8?q?tter,=20row=20model,=20per-row=20controls,=20lock-all/remove-ru?= =?UTF-8?q?le,=20Lock=20selection=20to=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/instrumentserver/gui/instruments.py | 703 +++++++++++++++++++++++- src/instrumentserver/gui/shortcuts.py | 1 + test/pytest/test_pm_gui.py | 428 ++++++++++++++- 3 files changed, 1110 insertions(+), 22 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 9ca6364..5b84799 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1067,6 +1067,38 @@ def sizeHint( LOCK_COLOUR = "#7e5bef" +def lock_button_tooltip(locked: bool, target: str) -> str: + """The lock/relock button's tooltip for one Lock state (the mock's + strings), with ``target`` relative to the Parameter Manager. Shared by + the tree's per-row widget (plan task 5.3) and the Locks panel (plan + task 5.4).""" + if locked: + return f"locked to {target} — unlock and go back to its own value" + return f"unlocked — lock to {target} again" + + +def make_lock_button( + parent: QtWidgets.QWidget, locked: bool, target: Optional[str] = None +) -> QtWidgets.QPushButton: + """The lock/relock toggle button shared by the tree's per-row widget + (plan task 5.3) and the Locks panel (plan task 5.4): the lock icon and + the purple ``locked`` fill. ``target`` is the Target relative to the + Parameter Manager for the state tooltip; the tree's delegate passes + ``None`` and leaves the tooltip to + :meth:`ParameterManagerGui._update_row_lock_widget`.""" + button = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/lock.svg"), "", parent=parent + ) + button.setProperty("locked", locked) + button.setStyleSheet( + f"QPushButton[locked=\"true\"] {{ background-color: {LOCK_COLOUR} }}" + ) + if target is not None: + button.setToolTip(lock_button_tooltip(locked, target)) + keepSmallHorizontally(button) + return button + + def relative_path(full: str, instrument_name: str) -> str: """The path relative to the Parameter Manager: ``full`` with the ``.`` prefix stripped. ``PMLockBluePrint.target`` @@ -1186,6 +1218,138 @@ def own_rel(candidate: str) -> Optional[str]: return [path for _, path in ranked] +@dataclass +class LockRow: + """One row of the Locks panel (plan task 5.4): a Target of one or more + Locks — plain, or the Target of a Type Lock — and the Followers beneath + it, recursively for chains. ``type_locks`` holds every ``(Type name, + entry path)`` whose Type Lock Target the row is; ``lock`` is the row's + own Lock (``None`` for a plain Target).""" + + path: str + type_locks: List[Tuple[str, str]] + lock: Optional[PMLockBluePrint] + children: List["LockRow"] + + +def lock_root( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> str: + """The end of the chain of locked Locks that starts at ``path`` (the + mock's ``root``): the parameter a locked read at ``path`` finally asks. + Only locked hops count (D7): an unlocked Lock answers ``get`` with its + own value, so the walk stops there. A ``seen`` set guards against a + cycle. Paths are relative to the Parameter Manager, except the stored + ``PMLockBluePrint.target``, which is relativized on the way.""" + current = path + seen: set = set() + while current not in seen: + seen.add(current) + lock = locks.get(current) + if lock is None or not lock.locked: + return current + current = relative_path(lock.target, instrument_name) + return current + + +def build_lock_rows( + locks: Mapping[str, PMLockBluePrint], + types: Mapping[str, PMTypeBluePrint], + instrument_name: str, +) -> List[LockRow]: + """The Locks panel's rows from the client-side state (plan task 5.4; + the mock's locks-panel walk). + + ``locks`` maps each Follower's path (relative to the Parameter + Manager) to its :class:`PMLockBluePrint`; ``types`` maps each Type's + name to its :class:`PMTypeBluePrint`. An unlocked Lock still + remembers its Target (D5), so a Follower's link is its Lock's Target + whether the Lock is locked or not. + + The Targets are the unique links in ``locks`` order. The roots are the + Targets that carry no Lock of their own, the Type Lock Targets first + (a stable sort, like the mock's "group rows first"), each walked + recursively into its Followers — a ``seen`` set guards against loops — + and then any Target the first walk did not reach (the mock's second + pass, e.g. a cycle among Followers). Every row carries its own Lock + (``None`` for a plain Target) and its ``(Type, entry)`` pairs. + """ + + def link(follower: str) -> Optional[str]: + lock = locks.get(follower) + return ( + None if lock is None else relative_path(lock.target, instrument_name) + ) + + targets: List[str] = [] + for follower in locks: + target = link(follower) + if target is not None and target not in targets: + targets.append(target) + + def type_locks_at(path: str) -> List[Tuple[str, str]]: + found: List[Tuple[str, str]] = [] + for type_name, blueprint in types.items(): + for entry_path, spec in blueprint.parameters.items(): + entry_target = spec.get("target") + if ( + entry_target is not None + and relative_path(entry_target, instrument_name) == path + ): + found.append((type_name, entry_path)) + return found + + def followers(path: str) -> List[str]: + return [ + follower for follower in locks if link(follower) == path + ] + + rows: List[LockRow] = [] + seen: set = set() + + def walk(path: str) -> Optional[LockRow]: + if path in seen: + return None + seen.add(path) + row = LockRow( + path=path, + type_locks=type_locks_at(path), + lock=locks.get(path), + children=[], + ) + for child_path in followers(path): + child = walk(child_path) + if child is not None: + row.children.append(child) + return row + + # Group rows first: a Type Lock Target is the headline, single links + # follow (the mock's stable sort). + roots = [target for target in targets if target not in locks] + roots.sort(key=lambda target: 0 if type_locks_at(target) else 1) + for target in roots: + row = walk(target) + if row is not None: + rows.append(row) + # the mock's second pass: any Target the first walk did not reach + for target in targets: + row = walk(target) + if row is not None: + rows.append(row) + return rows + + +def _lock_row_paths(rows: List[LockRow]) -> List[str]: + """Every row path of the built rows, depth first.""" + paths: List[str] = [] + for row in rows: + paths.append(row.path) + paths.extend(_lock_row_paths(row.children)) + return paths + + class LockArmStrip(QtWidgets.QWidget): """The arm strip under the toolbar while a Lock's Target is being picked (plan task 5.3): a label naming the Follower, a line edit with @@ -1307,6 +1471,341 @@ def disarm(self) -> None: self.clear_error() +#: The Locks panel's default note (the mock's ``lockNote``, in glossary +#: words): shown until an action error or a skipped-Lock warning replaces +#: it. +LOCK_PANEL_NOTE = ( + "A Type Lock row — marked with its Type — holds one value for every " + "Instance of that Type. Unlock a Follower to let it keep its own " + "value, remove its Lock to take it out; the lock button on the Type " + "Lock row locks them all again." +) + +#: Fixed pixel width of the Locks panel's value column (the mock's value +#: column) and of its buttons column. +LOCK_PANEL_VALUE_WIDTH = 200 +LOCK_PANEL_BUTTONS_WIDTH = 84 + +#: Data role under which a Locks panel row's path (relative to the +#: Parameter Manager) is stored on its first item, so the rows can be +#: found again after a rebuild. +LOCK_ROW_ROLE = cast( + "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 2 +) + + +class LocksPanel(QtWidgets.QWidget): + """The Locks panel right of the Parameter Manager tree (plan task 5.4; + the mock's locks panel): one root row per Target — the Type Lock + Targets first, labelled with their Type — with each Target's Followers + beneath it, recursively for chains. + + A Target row holds a value editor (a plain ``set`` on the Target); a + locked Follower row shows its value read-only, and every Follower row + carries the lock/relock toggle and the remove button. A Type Lock row + carries "lock all" and "remove rule". The panel never talks to the + Server itself: every action is emitted as a signal — + ``toggleLockRequested``, ``removeLockRequested``, ``lockAllRequested``, + ``removeRuleRequested`` and ``lockSelectionRequested`` — and the + Parameter Manager GUI, which owns the panel, performs it and reports + errors and skipped Locks on the note label.""" + + #: Signal(str) + #: Emitted when the user presses a Follower row's lock/relock button; + #: the path is relative to the Parameter Manager. + toggleLockRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the user presses a Follower row's remove button; the + #: path is relative to the Parameter Manager. + removeLockRequested = QtCore.Signal(str) + + #: Signal(str, str, str) + #: Emitted when the user presses a Type Lock row's "lock all" button: + #: the Type's name, the entry path, and the entry's stored Target + #: relative to the Parameter Manager. + lockAllRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses a Type Lock row's "remove rule" + #: button: the Type's name and the entry path. + removeRuleRequested = QtCore.Signal(str, str) + + #: Signal() + #: Emitted when the user presses "Lock selection to…". + lockSelectionRequested = QtCore.Signal() + + def __init__( + self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None + ) -> None: + super().__init__(parent) + self.instrument_name = instrument_name + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.model = QtGui.QStandardItemModel(0, 3, self) + self.model.setHorizontalHeaderLabels(["locks", "value", ""]) + + self.view = QtWidgets.QTreeView(self) + self.view.setModel(self.model) + self.view.setHeaderHidden(False) + self.view.setAlternatingRowColors(True) + self.view.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + header = self.view.header() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.resizeSection(1, LOCK_PANEL_VALUE_WIDTH) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.resizeSection(2, LOCK_PANEL_BUTTONS_WIDTH) + + self.lockSelectionButton = QtWidgets.QPushButton( + "Lock selection to…", self + ) + self.selectedLabel = QtWidgets.QLabel(self) + self.selectedLabel.setText("no parameter selected") + + self.noteLabel = QtWidgets.QLabel(self) + self.noteLabel.setWordWrap(True) + self.noteLabel.setText(LOCK_PANEL_NOTE) + + layout.addWidget(self.view, 1) + selectionRow = QtWidgets.QHBoxLayout() + selectionRow.setContentsMargins(0, 0, 0, 0) + selectionRow.addWidget(self.lockSelectionButton) + selectionRow.addWidget(self.selectedLabel, 1) + layout.addLayout(selectionRow) + layout.addWidget(self.noteLabel) + self.setLayout(layout) + + # The widgets of every panel row, keyed by the row path (relative + # to the Parameter Manager); :meth:`refresh_values` re-reads the + # values without a rebuild. + self.rowWidgets: Dict[str, Dict[str, Any]] = {} + + self.lockSelectionButton.clicked.connect(self.lockSelectionRequested) + + def rebuild( + self, + rows: List[LockRow], + elements: Mapping[str, Any], + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + ) -> None: + """Rebuild every row from ``rows`` (see :func:`build_lock_rows`). + + ``elements`` maps each row path to the row's parameter object (the + GUI resolves it through the Proxy or the local instrument); + ``types`` and ``locks`` are the client-side state the "lock all" + Target and the tooltips come from. Every row is expanded after the + rebuild; no collapsed state is kept.""" + self.model.removeRows(0, self.model.rowCount()) + self.rowWidgets = {} + self._build_rows(rows, elements, types, locks, self.model.invisibleRootItem()) + self.view.expandAll() + + def refresh_values(self, paths: Iterable[str]) -> None: + """Re-read the value of every named row the panel holds: + editors through :meth:`ParameterWidget.setWidgetFromParameter`, + the read-only labels of locked rows through a fresh ``get``. A row + whose widget is gone — a rebuild replaced it — is skipped.""" + for path in paths: + entry = self.rowWidgets.get(path) + if entry is None: + continue + try: + if entry.get("editor") is not None: + entry["editor"].setWidgetFromParameter() + elif ( + entry.get("label") is not None + and entry.get("element") is not None + ): + entry["label"].setText(str(entry["element"].get())) + except RuntimeError: + logger.debug( + f"Could not refresh the value of {path}. " + "Object is not being shown right now." + ) + + def show_error(self, text: str) -> None: + """Show an action error (the mock's ``lockError``) in red on the + note label.""" + self.noteLabel.setStyleSheet("QLabel { color: red }") + self.noteLabel.setText(text) + + def show_note(self, text: str) -> None: + """Show ``text`` on the note label in the normal colour (a + skipped-Lock warning, for example).""" + self.noteLabel.setStyleSheet("") + self.noteLabel.setText(text) + + def reset_note(self) -> None: + """Restore the default explanatory note.""" + self.show_note(LOCK_PANEL_NOTE) + + def _build_rows( + self, + rows: List[LockRow], + elements: Mapping[str, Any], + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + parent_item: QtGui.QStandardItem, + ) -> None: + for row in rows: + if row.type_locks: + label = ( + f"[type: {', '.join(t for t, _ in row.type_locks)}] {row.path}" + ) + else: + label = row.path + name_item = QtGui.QStandardItem(label) + name_item.setData(row.path, LOCK_ROW_ROLE) + value_item = QtGui.QStandardItem() + buttons_item = QtGui.QStandardItem() + parent_item.appendRow([name_item, value_item, buttons_item]) + self._build_row_widgets( + row, + elements.get(row.path), + types, + locks, + value_item, + buttons_item, + ) + self._build_rows( + row.children, elements, types, locks, name_item + ) + + def _build_row_widgets( + self, + row: LockRow, + element: Any, + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + value_item: QtGui.QStandardItem, + buttons_item: QtGui.QStandardItem, + ) -> None: + """Build one row's value cell (a read-only label for a locked + Follower, a value editor for every other row with a parameter) and + its buttons cell (the Type Lock controls, or the Follower's toggle + and remove), and record the widgets in ``rowWidgets``.""" + path = row.path + entry: Dict[str, Any] = { + "element": element, + "editor": None, + "label": None, + "toggle": None, + "remove": None, + "lockAll": None, + "removeRule": None, + } + self.rowWidgets[path] = entry + + if row.lock is not None and row.lock.locked: + # a locked Follower reads its Target's value (D3) and refuses + # writes: a read-only label, like the mock's + target = relative_path(row.lock.target, self.instrument_name) + root = lock_root(path, locks, self.instrument_name) + label = QtWidgets.QLabel(self.view.viewport()) + value = "" + if element is not None: + try: + value = element.get() + except Exception as exc: + logger.debug(f"could not read the value of {path}: {exc}") + label.setText(str(value)) + label.setToolTip(f"locked to {target} — set the value on {root}") + self.view.setIndexWidget(self.model.indexFromItem(value_item), label) + entry["label"] = label + elif element is not None: + editor = ParameterWidget(element, self.view.viewport()) + if row.type_locks: + tooltip = ( + f"set the value — every Instance of " + f"{row.type_locks[0][0]} follows it" + ) + else: + tooltip = "set the Target value — every locked Follower follows it" + editor.setButton.setToolTip(tooltip) + self.view.setIndexWidget(self.model.indexFromItem(value_item), editor) + entry["editor"] = editor + + container: Optional[QtWidgets.QWidget] = None + if row.type_locks: + # the Type Lock controls; like the mock, the first (Type, + # entry) pair acts when several share the Target + type_name, entry_path = row.type_locks[0] + blueprint = types.get(type_name) + spec = ( + blueprint.parameters.get(entry_path, {}) + if blueprint is not None + else {} + ) + stored = spec.get("target") + stored_relative = ( + relative_path(stored, self.instrument_name) + if stored is not None + else path + ) + container = QtWidgets.QWidget(self.view.viewport()) + buttons_layout = QtWidgets.QHBoxLayout(container) + buttons_layout.setContentsMargins(0, 0, 0, 0) + lock_all = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/lock.svg"), "", parent=container + ) + lock_all.setToolTip(f"lock every Instance of {type_name} to this again") + keepSmallHorizontally(lock_all) + lock_all.pressed.connect( + lambda: self.lockAllRequested.emit( + type_name, entry_path, stored_relative + ) + ) + remove_rule = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=container + ) + remove_rule.setStyleSheet("QPushButton { background-color: salmon }") + remove_rule.setToolTip( + "remove the Type Lock — the Instances' Locks stay until " + "removed one by one" + ) + keepSmallHorizontally(remove_rule) + remove_rule.pressed.connect( + lambda: self.removeRuleRequested.emit(type_name, entry_path) + ) + buttons_layout.addWidget(lock_all) + buttons_layout.addWidget(remove_rule) + entry["lockAll"] = lock_all + entry["removeRule"] = remove_rule + elif row.lock is not None: + # a Follower's lock/relock toggle and remove button + container = QtWidgets.QWidget(self.view.viewport()) + buttons_layout = QtWidgets.QHBoxLayout(container) + buttons_layout.setContentsMargins(0, 0, 0, 0) + target = relative_path(row.lock.target, self.instrument_name) + toggle = make_lock_button(container, row.lock.locked, target) + toggle.pressed.connect( + lambda follower=path: self.toggleLockRequested.emit(follower) + ) + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=container + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip(f"remove the Lock — {path} keeps its own value") + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda follower=path: self.removeLockRequested.emit(follower) + ) + buttons_layout.addWidget(toggle) + buttons_layout.addWidget(remove) + entry["toggle"] = toggle + entry["remove"] = remove + if container is not None: + self.view.setIndexWidget( + self.model.indexFromItem(buttons_item), container + ) + + # ----------------- Parameter Manager Locks - Ending ----------------------------------- @@ -1361,13 +1860,8 @@ def make_lock_widget( Lock (a Lock-less row shows no button, as the mock), fills purple while the Lock is locked, and only :meth:`ParameterManagerGui. apply_locks` changes its state.""" - w = QtWidgets.QPushButton(QtGui.QIcon(":/icons/lock.svg"), "", parent=widget) - w.setProperty("locked", False) - w.setStyleSheet( - f"QPushButton[locked=\"true\"] {{ background-color: {LOCK_COLOUR} }}" - ) + w = make_lock_button(widget, locked=False) w.setVisible(False) - keepSmallHorizontally(w) w.pressed.connect(lambda: self.toggleLock.emit(fullName)) return w @@ -1612,6 +2106,22 @@ def __init__( assert isinstance(layout, QtWidgets.QVBoxLayout) layout.insertWidget(0, self.profileManager) layout.addWidget(self.addParam) + # The Locks panel (plan task 5.4) sits right of the tree in a + # splitter: the view keeps its identity, so every existing layout + # consumer and test keeps working. The panel starts hidden and + # costs nothing until the toolbar action shows it. + self.locksPanel = LocksPanel(self.instrument.name, parent=self) + view_index = layout.indexOf(self.view) + layout.removeWidget(self.view) + self.locksSplitter = QtWidgets.QSplitter( + QtCore.Qt.Orientation.Horizontal, self + ) + self.locksSplitter.addWidget(self.view) + self.locksSplitter.addWidget(self.locksPanel) + self.locksSplitter.setStretchFactor(0, 3) + self.locksSplitter.setStretchFactor(1, 2) + layout.insertWidget(view_index, self.locksSplitter) + self.locksPanel.setVisible(False) # The existing content becomes tab 0 of the tab widget; the Types # tab stays an empty placeholder until its own task builds it. self.parametersTab = QtWidgets.QWidget(self) @@ -1668,11 +2178,25 @@ def connectSignals(self) -> None: self.view.clicked.connect(self._on_view_clicked) self.armStrip.targetPicked.connect(self.pick_lock_target) self.armStrip.cancelled.connect(self.cancel_arm) + # the Locks panel (plan task 5.4): its actions run through this GUI, + # and the tree's current row drives the panel's selected label + self.locksAction.toggled.connect(self._on_locks_action_toggled) + self.locksPanel.toggleLockRequested.connect(self._on_panel_toggle_lock) + self.locksPanel.removeLockRequested.connect(self._on_panel_remove_lock) + self.locksPanel.lockAllRequested.connect(self._on_panel_lock_all) + self.locksPanel.removeRuleRequested.connect(self._on_panel_remove_rule) + self.locksPanel.lockSelectionRequested.connect( + self._lock_selection_from_panel + ) + self.view.selectionModel().currentChanged.connect( + self._on_tree_current_changed + ) self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) self.shortcutManager.register("clear_add", self.addParam.clear, self) self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) self.shortcutManager.register("load_items", self.loadFromFile, self) self.shortcutManager.register("save_items", self.saveToFile, self) + self.shortcutManager.register("toggle_locks", self.locksAction.toggle, self) @QtCore.Slot() def _deleteCurrentItem(self) -> None: @@ -1699,6 +2223,16 @@ def makeToolbar(self) -> QtWidgets.QToolBar: saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] self.shortcutManager.register_tooltip("save_items", saveParamAction) + # the Locks panel toggle (plan task 5.4); the toggled connection + # and the shortcut are wired in connectSignals, where the panel + # exists + self.locksAction = toolbar.addAction( + QtGui.QIcon(":/icons/lock.svg"), + "Show the Locks panel", + ) + self.locksAction.setCheckable(True) + self.shortcutManager.register_tooltip("toggle_locks", self.locksAction) + return toolbar def refreshAll(self) -> None: @@ -1747,9 +2281,11 @@ def _on_type_changed( ) -> None: """Record the change a ``pm-type-update`` Broadcast reports about the Type ``name`` in the state, then recompute the tints and gutter - bands it may change.""" + bands it may change, and rebuild the Locks panel (its Type Lock + rows depend on the Types).""" self.state.apply_type(name, type_blueprint) self.apply_tints() + self.refresh_locks_panel() @QtCore.Slot(str, object) def _on_lock_changed( @@ -1759,11 +2295,18 @@ def _on_lock_changed( the Follower at ``path``, then recompute the Lock column and the row widgets, and repaint the values the change alters: the Follower's own and every row whose chain of locked Locks reaches - it, since locking and unlocking change what ``get`` answers.""" + it, since locking and unlocking change what ``get`` answers — in + the tree and, while it is shown, in the Locks panel.""" self.state.apply_lock(path, lock) self.apply_locks() - for follower in [path, *followers_reaching(path, self.state.locks, self.instrument.name)]: + refreshed = [ + path, + *followers_reaching(path, self.state.locks, self.instrument.name), + ] + for follower in refreshed: self._refresh_row_widget(follower) + if not self.locksPanel.isHidden(): + self.locksPanel.refresh_values(refreshed) @QtCore.Slot(object, object) def _on_item_new_value(self, path: object, value: object) -> None: @@ -1772,11 +2315,15 @@ def _on_item_new_value(self, path: object, value: object) -> None: Follower answers ``get`` with the Target's value, and the Parameter Manager emits nothing for values). The Broadcast's own row is refreshed by the base wiring to - ``view.onItemNewValue``; this slot handles the rows behind it.""" - for follower in followers_reaching( + ``view.onItemNewValue``; this slot handles the rows behind it — + and, while the Locks panel is shown, the same paths there.""" + followers = followers_reaching( str(path), self.state.locks, self.instrument.name - ): + ) + for follower in followers: self._refresh_row_widget(follower) + if not self.locksPanel.isHidden(): + self.locksPanel.refresh_values([str(path), *followers]) def _refresh_row_widget(self, path: str) -> None: """Re-read the parameter behind the row at ``path`` through the @@ -1804,8 +2351,10 @@ def apply_locks(self) -> None: model reload), on every ``pm-lock-update`` Broadcast, and after a parameter was created or removed by a Broadcast. Recomputing all rows on every change is fine — the tree is small — and keeps one - clear path.""" + clear path. The Locks panel is rebuilt with the same state at the + end, but only while it is shown.""" self._apply_locks_to_rows(self.model.invisibleRootItem()) + self.refresh_locks_panel() def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: """Walk the source model (never the proxy) and set each row's Lock @@ -1847,12 +2396,7 @@ def _update_row_lock_widget( widget.set_read_only(False) return target = relative_path(lock.target, self.instrument.name) - if lock.locked: - tooltip = ( - f"locked to {target} — unlock and go back to its own value" - ) - else: - tooltip = f"unlocked — lock to {target} again" + tooltip = lock_button_tooltip(lock.locked, target) if button is not None: button.setToolTip(tooltip) button.setProperty("locked", lock.locked) @@ -1943,6 +2487,127 @@ def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: if item is not None and item.element is not None: self.pick_lock_target(item.name) + # ------------------------------------------------------------------ + # the Locks panel (plan task 5.4) + # ------------------------------------------------------------------ + + @QtCore.Slot(bool) + def _on_locks_action_toggled(self, checked: bool) -> None: + """Show or hide the Locks panel with the toolbar action, and + rebuild its rows when it becomes visible (a hidden panel costs + nothing).""" + self.locksPanel.setVisible(checked) + if checked: + self.refresh_locks_panel() + + @QtCore.Slot() + def refresh_locks_panel(self) -> None: + """Rebuild the Locks panel's rows from the client-side state (plan + task 5.4): the rows from ``PMState.locks`` and ``PMState.types``, + each row's parameter resolved through the instrument. + + Runs at the end of :meth:`apply_locks` and of + :meth:`_on_type_changed` — the Type Lock rows depend on the Types — + and when the toolbar action shows the panel, but only while the + panel is shown, so a hidden panel costs nothing.""" + if self.locksPanel.isHidden(): + return + rows = build_lock_rows( + self.state.locks, self.state.types, self.instrument.name + ) + elements: Dict[str, Any] = {} + for path in _lock_row_paths(rows): + try: + elements[path] = nestedAttributeFromString(self.instrument, path) + except (AttributeError, RuntimeError) as exc: + logger.debug( + f"could not resolve the parameter of the Locks panel " + f"row {path}: {exc}" + ) + self.locksPanel.rebuild( + rows, elements, self.state.types, self.state.locks + ) + + @QtCore.Slot(str) + def _on_panel_toggle_lock(self, path: str) -> None: + """The Locks panel's lock/relock toggle: toggle the Lock of the + parameter at ``path``. A refused toggle shows the Server's error + text on the panel's note label.""" + try: + self.instrument.toggle_lock(path) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + self.locksPanel.reset_note() + + @QtCore.Slot(str) + def _on_panel_remove_lock(self, path: str) -> None: + """The Locks panel's remove button: remove the Lock of the + parameter at ``path``. A refused removal shows the Server's error + text on the panel's note label.""" + try: + self.instrument.remove_lock(path) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + self.locksPanel.reset_note() + + @QtCore.Slot(str, str, str) + def _on_panel_lock_all(self, type_name: str, entry: str, target: str) -> None: + """The Type Lock row's "lock all" button: declare the Type Lock + again with the entry's stored Target — called with ``target=None`` + the Server would re-point the rule to the Globals default (D17). + Instance parameters the declaration skips, because they carry a + Lock on another Target (D17), are named on the note label.""" + try: + skipped = self.instrument.lock_type_parameter( + type_name, entry, target=target + ) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + if skipped: + self.locksPanel.show_note(f"skipped: {', '.join(skipped)}") + else: + self.locksPanel.reset_note() + + @QtCore.Slot(str, str) + def _on_panel_remove_rule(self, type_name: str, entry: str) -> None: + """The Type Lock row's "remove rule" button: remove only the rule + (D17); the Locks it created stay until they are removed one by + one. A refused removal shows the Server's error text on the + panel's note label.""" + try: + self.instrument.unlock_type_parameter(type_name, entry) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + self.locksPanel.reset_note() + + @QtCore.Slot() + def _lock_selection_from_panel(self) -> None: + """The panel's "Lock selection to…": arm the target picker for the + tree's current parameter row. With no parameter row current, the + note label says so and nothing is armed.""" + item = self._getCurrentItem() + if item is None or item.element is None: + self.locksPanel.show_error("Select a parameter in the tree first.") + return + self.arm_lock(item.name) + + @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) + def _on_tree_current_changed( + self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex + ) -> None: + """Keep the panel's selected label on the tree's current row: a + parameter row shows its path, a submodule row or no selection shows + "no parameter selected".""" + item = self._getCurrentItem() + if item is not None and item.element is not None: + self.locksPanel.selectedLabel.setText(item.name) + else: + self.locksPanel.selectedLabel.setText("no parameter selected") + @QtCore.Slot() def apply_tints(self) -> None: """Recompute every row's Type claims and repaint the tints and diff --git a/src/instrumentserver/gui/shortcuts.py b/src/instrumentserver/gui/shortcuts.py index 77c8096..d635331 100644 --- a/src/instrumentserver/gui/shortcuts.py +++ b/src/instrumentserver/gui/shortcuts.py @@ -43,6 +43,7 @@ class KeyboardShortcutManager: "save_items": ("Ctrl+Shift+S", "Save parameters to JSON file"), "fit_column": ("Ctrl+Shift+D", "Fits column width"), "sort_column": ("Ctrl+D", "Toggle sorting of selected column"), + "toggle_locks": ("Ctrl+Shift+L", "Show or hide the Locks panel"), } def __init__(self) -> None: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 98ce824..88b3e57 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -1,6 +1,7 @@ """Client-side state and Broadcast handling of the Parameter Manager GUI -(plan task 5.1), its tabs, tints and gutter bands (plan task 5.2), and its -Lock column, lock toggle, context menu and arm strip (plan task 5.3). +(plan task 5.1), its tabs, tints and gutter bands (plan task 5.2), its +Lock column, lock toggle, context menu and arm strip (plan task 5.3), and +its Locks panel (plan task 5.4). The GUI keeps the Parameter Manager's Types and Locks in a ``PMState`` (``ParameterManagerGui.state``), filled from the Parameter Manager on @@ -14,7 +15,10 @@ edits produce live. The 5.3 tests cover the pure Lock helpers, the arm strip and the read-only rendering without a Server, and the arm-via-context-menu flow, the toggle, the refused cycle, the Follower repaint on a second -Client's Target update, and the model reload path live. +Client's Target update, and the model reload path live. The 5.4 tests cover +the pure Locks-panel row model without a Server, the toolbar action and +splitter, and the panel's rows, remove and lock-all actions, value editor +and "Lock selection to…" flow live. Two shapes of the live path are deliberately avoided in these tests, both pre-existing and outside this task's scope: @@ -46,6 +50,8 @@ GUTTER_WIDTH, LOCK_COLUMN, LOCK_COLUMN_WIDTH, + LOCK_PANEL_NOTE, + LOCK_ROW_ROLE, TINT_COLOURS, Claim, GutterDelegate, @@ -56,13 +62,16 @@ ParameterManagerTreeView, PMState, TypePalette, + build_lock_rows, compute_claims, followers_reaching, lock_column_text, + lock_root, rank_lock_targets, relative_path, ) from instrumentserver.gui.parameters import ParameterWidget +from instrumentserver.gui.shortcuts import KeyboardShortcutManager PM_NAME = "parameter_manager" PM_CLASS = "instrumentserver.params.ParameterManager" @@ -1475,3 +1484,416 @@ def _q01_if_is_mapped(mapped: bool): assert not widget.paramWidget.isEnabled() finally: gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.4: the Locks panel +# --------------------------------------------------------------------------- + + +def test_build_lock_rows_groups_followers_under_a_plain_target(): + """A plain Target with two Followers — one of them unlocked — builds + one root with two children, each child carrying its own Lock (locked + and unlocked alike, D5), and the root carrying none.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + } + rows = build_lock_rows(locks, {}, PM_NAME) + assert [row.path for row in rows] == ["q01.IF"] + root = rows[0] + assert root.lock is None + assert root.type_locks == [] + assert [child.path for child in root.children] == ["q02.IF", "q03.IF"] + assert root.children[0].lock == locks["q02.IF"] + assert root.children[1].lock == locks["q03.IF"] + assert root.children[0].children == [] + + +def test_build_lock_rows_walks_a_chain_nested_and_once(): + """A chain q03 → q02 → q01 nests two levels deep and the middle hop + q02 appears once.""" + locks = { + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + } + rows = build_lock_rows(locks, {}, PM_NAME) + assert [row.path for row in rows] == ["q01.IF"] + root = rows[0] + assert [child.path for child in root.children] == ["q02.IF"] + assert [ + grandchild.path for grandchild in root.children[0].children + ] == ["q03.IF"] + + +def test_build_lock_rows_sorts_the_type_lock_target_first(): + """A Type Lock Target sorts before a plain Target and carries the + (Type, entry) pairs whose stored Target it is.""" + dqubit = PMTypeBluePrint( + name="dqubit", + parameters={ + "IF": { + "default": None, + "unit": "Hz", + "target": f"{PM_NAME}.tshared", + } + }, + nested={}, + effective={"IF": {"unit": "Hz", "from_type": "dqubit"}}, + ) + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.plain.x", locked=True), + "dq01.IF": PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True), + } + rows = build_lock_rows(locks, {"dqubit": dqubit}, PM_NAME) + assert [row.path for row in rows] == ["tshared", "plain.x"] + assert rows[0].type_locks == [("dqubit", "IF")] + assert rows[1].type_locks == [] + assert [child.path for child in rows[0].children] == ["dq01.IF"] + + +def test_lock_root_follows_locked_hops_only(): + """lock_root walks locked Locks to the end of the chain and stops at + an unlocked hop, which answers ``get`` with its own value (D7).""" + locks = { + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + } + assert lock_root("q03.IF", locks, PM_NAME) == "q02.IF" + assert lock_root("q02.IF", locks, PM_NAME) == "q02.IF" + assert lock_root("q01.IF", locks, PM_NAME) == "q01.IF" + + all_locked = { + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + } + assert lock_root("q03.IF", all_locked, PM_NAME) == "q01.IF" + + +def _panel_row_items(gui, path): + """The three items of the Locks panel row ``path``: label, value and + buttons.""" + matches = [] + + def walk(parent): + for row in range(parent.rowCount()): + item = parent.child(row, 0) + if item is None: + continue + if item.data(LOCK_ROW_ROLE) == path: + matches.append( + [parent.child(row, column) for column in range(3)] + ) + walk(item) + + walk(gui.locksPanel.model.invisibleRootItem()) + assert matches, f"no Locks panel row {path!r}" + return matches[0] + + +def _panel_root_paths(gui): + """The paths of the Locks panel's depth-0 rows.""" + root = gui.locksPanel.model.invisibleRootItem() + return [root.child(row, 0).data(LOCK_ROW_ROLE) for row in range(root.rowCount())] + + +def _panel_child_paths(gui, path): + """The paths of the Locks panel row ``path``'s children.""" + item = _panel_row_items(gui, path)[0] + return [item.child(row, 0).data(LOCK_ROW_ROLE) for row in range(item.rowCount())] + + +def test_the_locks_action_toggles_the_panel(qtbot, pm, server_port): + """The toolbar action is checkable and unchecked, the panel starts + hidden as the splitter's second pane, and the shortcut is registered; + triggering the action shows the panel.""" + gui = _make_gui(qtbot, pm, server_port) + try: + assert gui.locksAction.isCheckable() + assert not gui.locksAction.isChecked() + assert gui.locksPanel.isHidden() + assert gui.locksSplitter.widget(0) is gui.view + assert gui.locksSplitter.widget(1) is gui.locksPanel + assert gui.parametersTab.isAncestorOf(gui.locksSplitter) + assert KeyboardShortcutManager.REGISTRY["toggle_locks"] == ( + "Ctrl+Shift+L", + "Show or hide the Locks panel", + ) + + gui.locksAction.trigger() + assert gui.locksAction.isChecked() + assert not gui.locksPanel.isHidden() + + gui.locksAction.trigger() + assert not gui.locksAction.isChecked() + assert gui.locksPanel.isHidden() + finally: + gui.model.stopListener() + + +def test_the_panel_rows_reflect_list_locks_and_remove_from_the_panel( + qtbot, pm, second_client, server_port +): + """The plan's named tests: a second Client's locked chain shows in the + panel as one root with its Followers beneath; the remove button removes + the Lock on the Server and the row disappears; a Lock the second Client + makes while the panel is open appears live; and the second Client + unlocking a Follower flips its toggle and gives it its editor back.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + second_pm.lock("q01.IF", "q02.IF") + second_pm.lock("q03.IF", "q01.IF") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + assert gui.state.locks == pm.list_locks() + + gui.locksAction.trigger() # shows the panel and rebuilds its rows + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["q02.IF"], + timeout=BROADCAST_TIMEOUT, + ) + assert _panel_child_paths(gui, "q02.IF") == ["q01.IF"] + assert _panel_child_paths(gui, "q01.IF") == ["q03.IF"] + + # the root is a plain Target: no buttons and a ParameterWidget + # value editor; its Followers are locked: a read-only label and the + # toggle and remove buttons + root_entry = gui.locksPanel.rowWidgets["q02.IF"] + assert isinstance(root_entry["editor"], ParameterWidget) + assert root_entry["toggle"] is None and root_entry["remove"] is None + follower_entry = gui.locksPanel.rowWidgets["q01.IF"] + assert follower_entry["label"] is not None + assert follower_entry["editor"] is None + assert follower_entry["toggle"] is not None + assert follower_entry["remove"] is not None + grandchild_entry = gui.locksPanel.rowWidgets["q03.IF"] + assert grandchild_entry["label"] is not None + + # press the remove button of q03.IF: the Lock goes on the Server + gui.locksPanel.rowWidgets["q03.IF"]["remove"].click() + qtbot.waitUntil( + lambda: pm.get_lock("q03.IF") is None, timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: "q03.IF" not in _panel_child_paths(gui, "q01.IF"), + timeout=BROADCAST_TIMEOUT, + ) + assert "q03.IF" not in gui.locksPanel.rowWidgets + + # live update: a Lock the second Client makes appears as a new + # child row + second_pm.lock("other.x", "q02.IF") + qtbot.waitUntil( + lambda: "other.x" in _panel_child_paths(gui, "q02.IF"), + timeout=BROADCAST_TIMEOUT, + ) + + # the second Client unlocks q01.IF: the toggle goes unlocked and + # the value cell becomes an editor again + second_pm.unlock("q01.IF") + + def _q01_unlocked_in_panel(): + entry = gui.locksPanel.rowWidgets.get("q01.IF") + return ( + entry is not None + and entry["toggle"] is not None + and entry["toggle"].property("locked") is False + and entry["editor"] is not None + ) + + qtbot.waitUntil(_q01_unlocked_in_panel, timeout=BROADCAST_TIMEOUT) + finally: + gui.model.stopListener() + + +def test_the_type_lock_rows_lock_all_and_remove_rule( + qtbot, pm, second_client, server_port +): + """A Type Lock Target is the panel's first root, labelled with its + Type; "remove rule" clears only the rule and leaves the Locks; and + "lock all" locks an unlocked Follower again through the stored + Target.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + second_pm.add_parameter("dq02.IF", initial_value=2.0, unit="Hz") + # a root-level parameter: the root is never an Instance, so targeting + # it cannot self-lock an Instance parameter + second_pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", default=1.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # an explicit Target, so no Globals parameter is created and no + # parameter-creation Broadcast hits the model's creation branch + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + qtbot.waitUntil( + lambda: gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared", + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["tshared"], + timeout=BROADCAST_TIMEOUT, + ) + assert _panel_row_items(gui, "tshared")[0].text() == "[type: dqubit] tshared" + assert _panel_child_paths(gui, "tshared") == ["dq01.IF", "dq02.IF"] + + # "remove rule": only the rule goes; both Locks stay + gui.locksPanel.rowWidgets["tshared"]["removeRule"].click() + qtbot.waitUntil( + lambda: pm.get_type("dqubit").parameters["IF"]["target"] is None, + timeout=BROADCAST_TIMEOUT, + ) + assert set(pm.list_locks()) == {"dq01.IF", "dq02.IF"} + qtbot.waitUntil( + lambda: _panel_row_items(gui, "tshared")[0].text() == "tshared", + timeout=BROADCAST_TIMEOUT, + ) + + # re-declare the Type Lock from the second Client, then unlock + # dq01.IF from there too; the panel's row carries its "lock all" + # button again once the Type Broadcast arrived and the rebuild ran + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + second_pm.unlock("dq01.IF") + qtbot.waitUntil( + lambda: pm.get_lock("dq01.IF") is not None + and pm.get_lock("dq01.IF").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("tshared") is not None + and gui.locksPanel.rowWidgets["tshared"]["lockAll"] is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + + # ... and "lock all" locks it again through the stored Target + gui.locksPanel.rowWidgets["tshared"]["lockAll"].click() + qtbot.waitUntil( + lambda: pm.get_lock("dq01.IF") is not None + and pm.get_lock("dq01.IF").locked, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_the_panel_value_editor_sets_the_target( + qtbot, pm, second_client, server_port +): + """Typing a value into the root Target's editor and pressing its set + button sets the parameter on the Server, and the tree's Follower row + repaints to it (5.3's repaint path).""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["q02.IF"], + timeout=BROADCAST_TIMEOUT, + ) + editor = gui.locksPanel.rowWidgets["q02.IF"]["editor"] + assert editor is not None + + editor.paramWidget.input.setText("11") + editor.setButton.click() + qtbot.waitUntil( + lambda: pm.q02.IF.get() == 11, timeout=BROADCAST_TIMEOUT + ) + tree_widget = gui.view.delegate.parameters["q01.IF"] + qtbot.waitUntil( + lambda: tree_widget._getMethod() == 11, timeout=BROADCAST_TIMEOUT + ) + finally: + gui.model.stopListener() + + +def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_port): + """"Lock selection to…" shows the tree's current parameter in the + selected label and arms the pick for it; on a submodule row it says so + on the note label and arms nothing.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + gui.locksAction.trigger() + assert gui.locksPanel.selectedLabel.text() == "no parameter selected" + + # select the other.x row in the tree: the label follows it + source_index = gui.model.indexFromItem(_row_items(gui, "other.x")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + qtbot.waitUntil( + lambda: gui.locksPanel.selectedLabel.text() == "other.x", + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksPanel.lockSelectionButton.click() + assert gui.armed_follower == "other.x" + assert not gui.armStrip.isHidden() + + # a fresh pick, then a submodule row: the label shows that no + # parameter is selected and pressing arms nothing + gui.cancel_arm() + source_index = gui.model.indexFromItem(_row_items(gui, "other")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + qtbot.waitUntil( + lambda: gui.locksPanel.selectedLabel.text() == "no parameter selected", + timeout=BROADCAST_TIMEOUT, + ) + gui.locksPanel.lockSelectionButton.click() + assert ( + "Select a parameter in the tree first." + in gui.locksPanel.noteLabel.text() + ) + assert gui.armed_follower is None + assert gui.armStrip.isHidden() + finally: + gui.model.stopListener() + + +def test_a_panel_action_error_shows_on_the_note_label(qtbot, pm, server_port): + """A refused panel action shows the Server's error text on the note + label, and the next successful action restores the default note.""" + pm.add_parameter("q01.x", initial_value=1.0, unit="Hz") + pm.add_parameter("q02.x", initial_value=2.0, unit="Hz") + pm.lock("q02.x", "q01.x") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.locksAction.trigger() + + gui.locksPanel.toggleLockRequested.emit("no.such") + assert "no.such" in gui.locksPanel.noteLabel.text() + + # a successful action restores the default note + gui.locksPanel.toggleLockRequested.emit("q02.x") + qtbot.waitUntil( + lambda: pm.get_lock("q02.x").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + assert gui.locksPanel.noteLabel.text() == LOCK_PANEL_NOTE + finally: + gui.model.stopListener() From ad3afbacaa45e5e4ce2a2c15f8bb0ee01033b780 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 13:16:27 -0500 Subject: [PATCH 078/107] 5.4: fix from review round 1: panel refresh, skipped-Lock note, stored-Target and toggle-click tests; glossary wording --- src/instrumentserver/gui/instruments.py | 18 +-- test/pytest/test_pm_gui.py | 148 +++++++++++++++++++++++- 2 files changed, 153 insertions(+), 13 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 5b84799..d4bc73d 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1265,19 +1265,19 @@ def build_lock_rows( ``locks`` maps each Follower's path (relative to the Parameter Manager) to its :class:`PMLockBluePrint`; ``types`` maps each Type's name to its :class:`PMTypeBluePrint`. An unlocked Lock still - remembers its Target (D5), so a Follower's link is its Lock's Target + remembers its Target (D5), so a Follower's Lock names its Target whether the Lock is locked or not. - The Targets are the unique links in ``locks`` order. The roots are the - Targets that carry no Lock of their own, the Type Lock Targets first - (a stable sort, like the mock's "group rows first"), each walked + The Targets are the unique Targets of the Locks, in ``locks`` order. + The roots are the Targets that carry no Lock of their own, the Type + Lock Targets first (a stable sort, like the mock's), each walked recursively into its Followers — a ``seen`` set guards against loops — and then any Target the first walk did not reach (the mock's second pass, e.g. a cycle among Followers). Every row carries its own Lock (``None`` for a plain Target) and its ``(Type, entry)`` pairs. """ - def link(follower: str) -> Optional[str]: + def target_of(follower: str) -> Optional[str]: lock = locks.get(follower) return ( None if lock is None else relative_path(lock.target, instrument_name) @@ -1285,7 +1285,7 @@ def link(follower: str) -> Optional[str]: targets: List[str] = [] for follower in locks: - target = link(follower) + target = target_of(follower) if target is not None and target not in targets: targets.append(target) @@ -1303,7 +1303,7 @@ def type_locks_at(path: str) -> List[Tuple[str, str]]: def followers(path: str) -> List[str]: return [ - follower for follower in locks if link(follower) == path + follower for follower in locks if target_of(follower) == path ] rows: List[LockRow] = [] @@ -1325,8 +1325,8 @@ def walk(path: str) -> Optional[LockRow]: row.children.append(child) return row - # Group rows first: a Type Lock Target is the headline, single links - # follow (the mock's stable sort). + # Type Lock Targets first, plain Targets follow — a stable sort, + # like the mock's roots = [target for target in targets if target not in locks] roots.sort(key=lambda target: 0 if type_locks_at(target) else 1) for target in roots: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 88b3e57..e5dd1ac 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -1491,7 +1491,7 @@ def _q01_if_is_mapped(mapped: bool): # --------------------------------------------------------------------------- -def test_build_lock_rows_groups_followers_under_a_plain_target(): +def test_build_lock_rows_nests_followers_under_a_plain_target(): """A plain Target with two Followers — one of them unlocked — builds one root with two children, each child carrying its own Lock (locked and unlocked alike, D5), and the root carrying none.""" @@ -1768,6 +1768,13 @@ def test_the_type_lock_rows_lock_all_and_remove_rule( and pm.get_lock("dq01.IF").locked is False, timeout=BROADCAST_TIMEOUT, ) + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("dq01.IF") is not None + and gui.state.locks["dq01.IF"].locked is False + ), + timeout=BROADCAST_TIMEOUT, + ) qtbot.waitUntil( lambda: ( gui.locksPanel.rowWidgets.get("tshared") is not None @@ -1783,6 +1790,87 @@ def test_the_type_lock_rows_lock_all_and_remove_rule( and pm.get_lock("dq01.IF").locked, timeout=BROADCAST_TIMEOUT, ) + # "lock all" passes the entry's stored Target: a call without it + # would re-point the rule to the Globals default (D17) + qtbot.waitUntil( + lambda: pm.get_type("dqubit").parameters["IF"]["target"] + == f"{PM_NAME}.tshared", + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("dq01.IF").target == f"{PM_NAME}.tshared" + assert not any(path.startswith("_globals") for path in pm.list()) + finally: + gui.model.stopListener() + + +def test_the_lock_all_note_names_the_skipped_followers( + qtbot, pm, second_client, server_port +): + """"lock all" names the Instance parameters it skips on the note + label and leaves them locked to their own Target (D17): one whose + Lock the second Client re-targeted keeps that Target.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + second_pm.add_parameter("dq02.IF", initial_value=2.0, unit="Hz") + # root-level parameters: the root is never an Instance, so targeting + # them cannot self-lock an Instance parameter + second_pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + second_pm.add_parameter("talt", initial_value=9.0, unit="Hz") + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", default=1.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # an explicit Target, so no Globals parameter is created and no + # parameter-creation Broadcast hits the model's creation branch + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + qtbot.waitUntil( + lambda: gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared", + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["tshared"], + timeout=BROADCAST_TIMEOUT, + ) + + # the second Client re-targets one Instance parameter; waiting for + # the GUI's state makes sure the rebuild that replaces the panel's + # buttons has run before the click + second_pm.lock("dq01.IF", "talt") + qtbot.waitUntil( + lambda: pm.get_lock("dq01.IF") is not None + and pm.get_lock("dq01.IF").target == f"{PM_NAME}.talt", + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("dq01.IF") is not None + and gui.state.locks["dq01.IF"].target == f"{PM_NAME}.talt" + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("tshared") is not None + and gui.locksPanel.rowWidgets["tshared"]["lockAll"] is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + + # "lock all": dq01.IF is skipped, named on the note label, and + # stays locked to its own Target + gui.locksPanel.rowWidgets["tshared"]["lockAll"].click() + qtbot.waitUntil( + lambda: "skipped: dq01.IF" in gui.locksPanel.noteLabel.text(), + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("dq01.IF").target == f"{PM_NAME}.talt" finally: gui.model.stopListener() @@ -1792,9 +1880,13 @@ def test_the_panel_value_editor_sets_the_target( ): """Typing a value into the root Target's editor and pressing its set button sets the parameter on the Server, and the tree's Follower row - repaints to it (5.3's repaint path).""" - second_pm = _second_parameter_manager(second_client) + repaints to it (5.3's repaint path). A second Client's set repaints + the panel in place: the root editor and the locked Follower's + read-only label both show the new value without a rebuild.""" + # the second Client's proxy is built after the parameters exist, so + # its blueprint knows q02.IF (attribute access resolves through it) _make_live_parameters(pm) + second_pm = _second_parameter_manager(second_client) gui = _make_gui(qtbot, pm, server_port) try: @@ -1824,6 +1916,28 @@ def test_the_panel_value_editor_sets_the_target( qtbot.waitUntil( lambda: tree_widget._getMethod() == 11, timeout=BROADCAST_TIMEOUT ) + + # the second Client's set reaches the panel without a local echo: + # the root editor and the locked Follower's read-only label are + # refreshed in place (a no-op refresh_values would fail here) + second_pm.q02.IF.set(21) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("q02.IF") is not None + and gui.locksPanel.rowWidgets["q02.IF"]["editor"] is not None + and gui.locksPanel.rowWidgets["q02.IF"]["editor"]._getMethod() + == 21 + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("q01.IF") is not None + and gui.locksPanel.rowWidgets["q01.IF"]["label"] is not None + and gui.locksPanel.rowWidgets["q01.IF"]["label"].text() == "21" + ), + timeout=BROADCAST_TIMEOUT, + ) finally: gui.model.stopListener() @@ -1875,7 +1989,9 @@ def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_po def test_a_panel_action_error_shows_on_the_note_label(qtbot, pm, server_port): """A refused panel action shows the Server's error text on the note - label, and the next successful action restores the default note.""" + label, and the next successful action restores the default note. The + panel's toggle button runs the same action as the signal: unlock, then + lock again.""" pm.add_parameter("q01.x", initial_value=1.0, unit="Hz") pm.add_parameter("q02.x", initial_value=2.0, unit="Hz") pm.lock("q02.x", "q01.x") @@ -1885,6 +2001,30 @@ def test_a_panel_action_error_shows_on_the_note_label(qtbot, pm, server_port): try: gui.locksAction.trigger() + # the panel's toggle button is wired to the same action: unlock, + # then lock again; the rebuild replaces the button, so it is + # re-read from rowWidgets before the second click + gui.locksPanel.rowWidgets["q02.x"]["toggle"].click() + qtbot.waitUntil( + lambda: pm.get_lock("q02.x").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + + def _unlocked_toggle_back(): + entry = gui.locksPanel.rowWidgets.get("q02.x") + return ( + entry is not None + and entry["toggle"] is not None + and entry["toggle"].property("locked") is False + ) + + qtbot.waitUntil(_unlocked_toggle_back, timeout=BROADCAST_TIMEOUT) + gui.locksPanel.rowWidgets["q02.x"]["toggle"].click() + qtbot.waitUntil( + lambda: pm.get_lock("q02.x").locked is True, + timeout=BROADCAST_TIMEOUT, + ) + gui.locksPanel.toggleLockRequested.emit("no.such") assert "no.such" in gui.locksPanel.noteLabel.text() From c677376d1d31d69b3c265500b5d803d003c8f993 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 13:29:57 -0500 Subject: [PATCH 079/107] 5.4: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 40 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 1b42b3a..7670e3c 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -847,3 +847,43 @@ The Parameter Manager GUI now keeps a client-side copy of the Types and Locks. ` - The orchestrator blanked its own list of reviewer handles with a bad shell pipeline. Five reviewers waited on permission prompts for about 10 minutes until the orchestrator swept them. - Five permission requests were rejected, all inline Python probes that came through truncated in the prompt: one from the coder, and one each from reviewer-glm, reviewer-qwen, plan-checker-glm and plan-checker-qwen. Each reran its probe as a scratch file under `orchestration/5.3/`. - The watcher's mkdir-prefix rule let the coder's icon `cp` through automatically. The orchestrator would have allowed it anyway, and it tightened the rule afterwards. + +## 5.4 Locks panel — 2026-09-28 + +The Parameters tab now holds `gui.view` and a new `LocksPanel` side by side in a horizontal `QSplitter` (`self.locksSplitter`, stretch 3:2). The panel is hidden until the checkable toolbar action `self.locksAction` (lock icon, `Ctrl+Shift+L`, REGISTRY key `toggle_locks`) shows it. Its rows come from the pure function `build_lock_rows(locks, types, instrument_name)`, which returns `LockRow`s: Targets at depth 0, Type Lock Targets first and labelled `[type: ] `, and Followers nested beneath them, recursively for chains. The panel only emits signals, and `ParameterManagerGui` slots (`_on_panel_toggle_lock`, `_on_panel_remove_lock`, `_on_panel_lock_all`, `_on_panel_remove_rule`, `_lock_selection_from_panel`) make the Server calls and show errors or skipped Locks on the panel's `noteLabel`. `test_pm_gui.py` grew from 42 to 53 tests. + +### Commit by commit +- `74d2d9f` The splitter, `LocksPanel`, the row model, the per-row controls and 10 tests (shortcuts.py gains one REGISTRY entry, added last because `test_shortcuts.py` pins the first key). The orchestrator's coder spec set thirteen readings. The main ones: + - The panel keeps `rowWidgets` per path. A locked Follower's value cell is a read-only label whose tooltip names the end of its chain, found by the new `lock_root`, which follows locked hops only (D7). Every other row (plain Target, Type Lock Target, unlocked Follower) gets a `ParameterWidget` doing a plain `set`. + - Follower rows get a lock/relock toggle and a remove button. Type Lock rows get "lock all" and "remove rule" and use the first `(Type, entry)` pair when there are several, as the mock does. A row that is both a Follower and a Type Lock Target gets only the Type Lock controls. + - "lock all" passes the entry's stored Target to `lock_type_parameter`, because `target=None` re-points the entry to the Globals default. The skipped paths it returns show as `skipped: , ` on the note. + - `refresh_locks_panel` runs at the end of `apply_locks` and `_on_type_changed`, and when the action shows the panel, but only while the panel is shown. `LocksPanel.refresh_values` repaints values in place from `_on_item_new_value` and `_on_lock_changed`, for the same path set they already compute (X plus `followers_reaching`). + - `selectedLabel` follows the tree's current row. "Lock selection to…" calls `arm_lock` for it, or shows "Select a parameter in the tree first." on a submodule row. + - Every live test creates its parameters before the GUI, and every live `lock_type_parameter` passes an existing Target (`tshared`), because of the 5.1 `parameter-creation` crash. + + The coder's own choices: 5.3's `make_lock_widget` now builds its button through the shared module-level `make_lock_button` and `lock_button_tooltip`, with the tree's behaviour unchanged (reviewer-glm and plan-checker-qwen checked it). `LockArmStrip` is untouched, so its stale Return docstring from 5.3 stays. Two test-side fixes: one `waitUntil` was racing the rebuild, and `pm.get("q02.IF")` read the proxy's stale parameters dict, so the test uses `pm.q02.IF.get()`. + + The tests: four no-server tests of `build_lock_rows` (a plain Target with a locked and an unlocked Follower, the chain `q03 → q02 → q01` nested with `q02` once, the Type Lock Target sorted first with `type_locks == [("dqubit", "IF")]`) and `lock_root`; `test_the_locks_action_toggles_the_panel`; and five live tests. `test_the_panel_rows_reflect_list_locks_and_remove_from_the_panel` holds the plan's three named tests: the chain's rows and widget kinds, the remove button removing `q03.IF`'s Lock on the Server, a second Client's `other.x` Lock appearing live, and a second Client's unlock flipping `q01.IF`'s toggle and giving it an editor. The others are `test_the_type_lock_rows_lock_all_and_remove_rule`, `test_the_panel_value_editor_sets_the_target`, `test_lock_selection_to_arms_the_tree_row` and `test_a_panel_action_error_shows_on_the_note_label`. Orchestrator run: ruff clean, 62 in the three named GUI files, 518 in the full suite. +- `ad3afba` Fix from round 0, five items: + - `test_the_panel_value_editor_sets_the_target` now has the second Client set `q02.IF` to 21 and waits for the root editor and `q01.IF`'s read-only label to show it. Neither has a local echo, so a no-op `refresh_values` fails. The second Client's proxy is now built after `_make_live_parameters`, since attribute access resolves through the blueprint fetched when the proxy is built. Both test reviewers raised it (should-fix). + - New test `test_the_lock_all_note_names_the_skipped_followers`: the second Client re-targets `dq01.IF` to a new root parameter `talt`, and "lock all" shows `skipped: dq01.IF` while `dq01.IF` stays locked to `talt` (D17). The fix list allowed a sibling test instead of extending the Type Lock test. Both test reviewers raised it (should-fix). + - The Type Lock test now pins the stored Target after "lock all": the entry's `target` stays `tshared`, `dq01.IF` is locked to `tshared`, and no `_globals` parameter exists. Before, a `target=None` call would only have failed indirectly, through the avoided creation crash. It also waits for `gui.state` before the rebuilt-button wait. test-reviewer-glm raised it as should-fix and test-reviewer-qwen as a nit. + - `test_a_panel_action_error_shows_on_the_note_label` now clicks the panel's toggle button (unlock, then lock again, re-reading the button from `rowWidgets` after each rebuild). Before, only the signal was emitted. test-reviewer-glm raised it (should-fix), and the orchestrator confirmed by grep that no test clicked a panel toggle. + - Plan rule 2: `build_lock_rows`'s helper `link` became `target_of`, and its docstring and sort comment lost "link" and the mock's "group rows first". The unit test became `test_build_lock_rows_nests_followers_under_a_plain_target`. Both plan checkers raised it as a nit, and both noted that the orchestrator's reading 4 had quoted the mock's names. It was sent because it is a plan rule, as in 5.2 and 5.3. The `src/` diff is wording and the rename only. + + All six approved in re-review with no new findings. Orchestrator run: ruff clean, 63 in the three named GUI files, 519 in the full suite. + +### Dropped findings +- On a `pm-lock-update` with the panel shown, `_on_lock_changed` rebuilds the panel (which reads every value) and then `refresh_values` reads the same values again (reviewer-glm, nit) → not sent. Reading 6 prescribes both, and the panel is small. +- The `else path` fallback for the stored Target in `_build_row_widgets` can never run, because `build_lock_rows` only records pairs with a Target (reviewer-glm, nit) → not sent. It is still in the code. +- Test nits, not sent: the multi-Type label `[type: , ]`, the cycle guards in `build_lock_rows`/`lock_root`, the tooltips, the `Ctrl+Shift+L` keypress and `expandAll` are all unasserted (test-reviewer-glm, test-reviewer-qwen). + +### Loose ends +- A successful "Lock selection to…" does not clear a stale error on the note, while the mock clears it on a successful arm (reviewer-qwen). Logged in `decisions.md` for 5.6 polish. +- Text typed into a panel editor but not set is lost on every rebuild (any Lock or Type Broadcast, a creation/deletion Broadcast, a filter keystroke). The tree behaves the same since 5.3, and the mock kept drafts (reviewer-qwen). +- The `LocksPanel` docstring says every Follower row has the toggle and remove buttons, which is not true for a Follower that is also a Type Lock Target (reviewer-qwen). +- The 5.1 `parameter-creation` crash (TEST_AUDIT.md) still means no GUI test covers a Type Lock on the default Globals Target. 5.3's stale `LockArmStrip` Return docstrings are also still there. + +### Process notes +- The coder sat idle for about 15 minutes after its read pass, and one nudge got it going, the same pattern as in 2.3 and 5.3. +- Two permission requests were rejected. The coder's `rm -rf orchestration/5.4` would have deleted the orchestrator's files (the 2.4 coder tried the same), so it deleted its own logs by name instead. plan-checker-qwen asked to access opencode's temp directory under `/var/folders`, outside the repo. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index edd9f79..1c9325c 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -579,7 +579,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session Tests: `test_pm_gui.py` — arm via context menu, pick a row, assert `get_lock` on the server; toggle unlocks; a cycle attempt shows the error; setting the Target from a second client repaints the Follower row. -- [ ] **5.4 Locks panel.** Toolbar action (lock icon, checkable, `Ctrl+Shift+L`) toggling a +- [x] **5.4 Locks panel.** Toolbar action (lock icon, checkable, `Ctrl+Shift+L`) toggling a second `QTreeView` in a `QSplitter` right of the tree. Rows built from `PMState.locks`: Targets at depth 0 (Type Lock Targets first, labelled `[type: ] `), Followers beneath, recursively for chains. Per Follower row: lock/relock toggle and remove From 7c9254291c040f2a4529ecfeef1468c96434747b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 14:36:50 -0500 Subject: [PATCH 080/107] 5.5: Types tab with three panes, and the parameter-creation branch fix --- TEST_AUDIT.md | 2 +- src/instrumentserver/gui/instruments.py | 1363 ++++++++++++++++++++++- test/pytest/test_pm_gui.py | 799 ++++++++++++- 3 files changed, 2104 insertions(+), 60 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 72d8ccf..4d7deae 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -42,7 +42,7 @@ States: | user_guide/parameter_manager.md (future) | Profiles — loading a file | `ParameterManager.fromFile` accepts `deleteMissing` but never forwards it to `fromParamDict`, so the GUI's `fromFile(filePath=..., deleteMissing=False)` runs with the default `True` | Found during the plan 4.1 review (reviewer-glm, the coder) | gap | Pre-existing; not changed per plan rule 6; fix is a one-line forward plus a test | | user_guide/parameter_manager.md (future) | Profiles — file validation | Both `schemas/parameters.json` and `schemas/parameter_manager_v2.json` use `patternProperties` without `additionalProperties: false`, so a parameter key that does not match `^(\w+)(\.\w+)*$` (e.g. with a space) passes validation and fails later in the loader, and a document listing both a parameter key and a dotted extension of it (`params.q01` and `params.q01.x`), or the bare key `params._globals`, passes validation and fails mid-load in the parameters step (a parameter cannot have child parameters) or silently shadows a submodule; inherited from the legacy reader | Found during the plan 4.1 review (plan-checker-qwen); extended during the plan 4.2 review | gap | Pre-existing in the legacy schema the plan protects; not changed per plan rule 6 | | user_guide/parameter_manager.md (future) | Types — empty Type name | `add_type("")` succeeds (only `_globals` is refused, task 2.1), so a manager can hold an empty-named Type that `toFile` writes and the version-2 reader refuses; such a manager cannot round-trip | Found during the plan 4.2 review (test-reviewer-glm) | gap | Pre-existing since 2.1; not changed per plan rule 6; fix is an empty-name refusal in `add_type` plus a test | -| gui_features.md (future) | Parameter Manager GUI — live creation from another client | `ModelParameters.updateParameter`'s `parameter-creation` branch calls `instrument.update()` and then `nestedAttributeFromString` on the Proxy Instrument; a parameter another Client creates while the GUI is open raises `AttributeError` there (stale Proxy blueprint), so the row never appears | Found during the plan 5.1 work (coder probe, verified pre-existing by all six reviewers) | gap | Pre-existing; not changed per plan rule 6; to be looked at with the 5.x live-update work or in 6.3 | +| gui_features.md (future) | Parameter Manager GUI — live creation from another client | `ModelParameters.updateParameter`'s `parameter-creation` branch calls `instrument.update()` and then `nestedAttributeFromString` on the Proxy Instrument; a parameter another Client creates while the GUI is open raises `AttributeError` there (stale Proxy blueprint), so the row never appears | Found during the plan 5.1 work (coder probe, verified pre-existing by all six reviewers) | fixed | Fixed in plan task 5.5 by Marcos's decision (rule 6 exception): the branch resolves the element first and, on `AttributeError`, refreshes the stale Proxy blueprint and resolves again; regression tests live in `test/pytest/test_pm_gui.py` | | user_guide/parameter_manager.md (future) | Profiles — GUI start with no profile file | `ParameterManagerGui.__init__` calls `loadProfile`, which calls `switch_to_profile` with the combo's current text; with no profile file present `switch_to_profile` raises, so the GUI cannot be built until one profile exists | Found during the plan 5.1 work (coder probe) | gap | Pre-existing; not changed per plan rule 6 | | gui_features.md (future) | Parameter Manager GUI — `parameter-update` for a row with no widget | `ParameterManagerTreeView.onItemNewValue` indexes `self.delegate.parameters[itemName]` without a guard, so a `parameter-update` Broadcast for a row whose editor widget was never created raises `KeyError` and the value never shows | Found during the plan 5.3 round-0 review (reviewer-qwen) | gap | Pre-existing; not changed per plan rule 6; the new `ParameterManagerGui._on_item_new_value` guards with `.get` and logs instead of raising | diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index d4bc73d..e65c391 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1,3 +1,4 @@ +import ast import inspect import logging from dataclasses import dataclass @@ -477,13 +478,29 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: fullName = ".".join(bp.name.split(".")[1:]) if bp.action == PARAMETER_CREATION: - if fullName not in self.instrument.list(): - self.instrument.update() - if fullName in self.instrument.list(): - self.addItem( - fullName, - element=nestedAttributeFromString(self.instrument, fullName), - ) + # Resolve the parameter on the instrument first: a parameter + # another Client created while the GUI is open is already in the + # Proxy's remote list(), so only a fresh resolution tells whether + # the (Proxy) instrument's blueprint is stale. On a stale one, + # update() refreshes it and the element resolves on the second + # attempt (TEST_AUDIT.md, "Parameter Manager GUI — live creation + # from another client"; fixed in plan task 5.5 by Marcos's + # decision, an explicit exception to plan rule 6). + try: + element = nestedAttributeFromString(self.instrument, fullName) + except AttributeError: + if hasattr(self.instrument, "update"): + self.instrument.update() + try: + element = nestedAttributeFromString(self.instrument, fullName) + except AttributeError: + logger.debug( + f"Ignoring parameter-creation broadcast for a " + f"parameter that cannot be resolved: {fullName}" + ) + element = None + if element is not None: + self.addItem(fullName, element=element) elif bp.action == PARAMETER_DELETION: self.removeItem(fullName) @@ -843,6 +860,22 @@ def _carries_effective_set( return True +def _instance_candidates(parameters: Mapping[str, str]) -> List[str]: + """Every submodule path the parameter rows imply, sorted: every proper + dotted prefix of a parameter path, never the root and never anything + under the Globals submodule (D12). This is the candidate set both + :func:`compute_claims` and :func:`instances_of_type` match against.""" + candidates = set() + for path in parameters: + segments = path.split(".") + for depth in range(1, len(segments)): + candidate = ".".join(segments[:depth]) + if "_globals" in candidate.split("."): + continue # Globals is excluded from matching at any depth + candidates.add(candidate) + return sorted(candidates) + + def compute_claims( types: Mapping[str, PMTypeBluePrint], parameters: Mapping[str, str], @@ -868,14 +901,7 @@ def compute_claims( defines is claimed by it, with the outer Types behind it in the stack. """ # candidate Instances: every proper dotted prefix of a parameter path - candidates = set() - for path in parameters: - segments = path.split(".") - for depth in range(1, len(segments)): - candidate = ".".join(segments[:depth]) - if "_globals" in candidate.split("."): - continue # Globals is excluded from matching at any depth - candidates.add(candidate) + candidates = _instance_candidates(parameters) claims_by_path: Dict[str, List[Tuple[str, str, int]]] = {} winning: Dict[str, Tuple[str, str, int]] = {} @@ -900,7 +926,7 @@ def put(path: str, type_name: str, instance: str, size: int) -> None: continue # an empty Type has no Instances size = len(effective) at_by_path = _nested_claim_prefixes(blueprint, types) - for instance in sorted(candidates): + for instance in candidates: if not _carries_effective_set(instance, effective, parameters): continue # the Instance row itself is claimed by its Type, as in the mock @@ -1176,25 +1202,30 @@ def rank_lock_targets( follower: str, candidates: Iterable[str], claims: Mapping[str, Claim], + arm_rel: Optional[str] = None, ) -> List[str]: """The arm strip's Target candidates in the mock's completer order. ``arm_rel`` is the Follower's path relative to its Instance (the part behind the Claiming Type's Instance path), or ``None`` when the - Follower is claimed by no Type. Rank 0: the candidate's own relative - path equals ``arm_rel`` (the same leaf on a sibling Instance, the - mock's first pick). Rank 1: ``.`` occurs in the candidate + Follower is claimed by no Type; an explicit ``arm_rel`` argument + overrides it, which the Types tab's Type Lock re-target (plan task + 5.5) uses to rank for a Type's entry path — there is no claimed + Follower and so nothing to exclude. Rank 0: the candidate's own + relative path equals ``arm_rel`` (the same leaf on a sibling Instance, + the mock's first pick). Rank 1: ``.`` occurs in the candidate (a submodule on the way). Rank 2: everything else. Equal ranks order alphabetically; the Follower itself is never a candidate. Cycles are not filtered here: the Server refuses them and the arm strip shows its error text. """ - follower_claim = claims.get(follower) - arm_rel = ( - follower[len(follower_claim.instance) + 1:] - if follower_claim is not None - else None - ) + if arm_rel is None: + follower_claim = claims.get(follower) + arm_rel = ( + follower[len(follower_claim.instance) + 1:] + if follower_claim is not None + else None + ) def own_rel(candidate: str) -> Optional[str]: claim = claims.get(candidate) @@ -1809,6 +1840,1008 @@ def _build_row_widgets( # ----------------- Parameter Manager Locks - Ending ----------------------------------- +# ----------------- Parameter Manager Types tab - Beginning ---------------------------- + + +@dataclass +class EntryRow: + """One row of the Types tab's entries pane (plan task 5.5; the mock's + ``tParamRows``): a submodule row of the selected Type's tree, or one + entry of it. + + ``kind`` is ``"submodule"`` or ``"entry"``. A submodule row carries + ``nested_type`` — the Type required at that submodule, or ``None`` for + a structural row that only carries the rows below it. An entry row + carries + the effective entry's ``unit`` and defining Type (``from_type``), + whether the selected Type defines the entry itself (``own``), its + ``default`` — an own entry's stored default, a Nested Type entry's + default as stored on the defining Type — and, own entries only, the + ``target`` of the entry's Type Lock relative to the Parameter Manager + (``None`` while it has none). + """ + + path: str + kind: str + unit: str = "" + nested_type: Optional[str] = None + own: bool = False + from_type: Optional[str] = None + default: Any = None + target: Optional[str] = None + + +def _nested_type_at( + blueprint: PMTypeBluePrint, + types: Mapping[str, PMTypeBluePrint], + submodule: str, +) -> Optional[str]: + """The Type required at the submodule ``submodule`` (a dotted path + relative to the Type ``blueprint``): the walk follows the ``nested`` + maps down the segments, the way :func:`_nested_claim_prefixes` walks. + ``None`` when no Nested Type is required there — a structural row — + or when a nested Type of the chain is missing from ``types``.""" + current = blueprint + for segment in submodule.split("."): + if current is None: + return None + nested_name = current.nested.get(segment) + if nested_name is None: + return None + current = types.get(nested_name) + return current.name if current is not None else None + + +def type_entry_rows( + type_name: str, + types: Mapping[str, PMTypeBluePrint], + instrument_name: str = "", +) -> List[EntryRow]: + """The entries-pane rows of the Type ``type_name`` (plan task 5.5): + its effective parameter set as a segment-sorted tree of submodule and + entry rows. + + The sort is segment-wise like the mock's (paths order by their dotted + segments), so a submodule row sorts directly before the rows below it + and the list reads as a tree in order. + + An entry the Type defines itself (``from_type == type_name``) is + ``own``: its ``default`` and ``target`` come from the Type's own + entry, with the stored Type Lock Target relativized with + ``instrument_name``. An entry a Nested Type defines shows that Type + as ``from_type`` and the defining Type's own default for the path + relative to it (the mock's ``ownerRel``). + + :param type_name: the selected Type's name. + :param types: the Parameter Manager's Types (``PMState.types``). + :param instrument_name: the Parameter Manager's name, for + relativizing the stored Type Lock Targets; without it the stored + full-form Targets are returned unchanged. + :return: the rows, parents before children. + """ + blueprint = types.get(type_name) + if blueprint is None: + return [] + at_by_path = _nested_claim_prefixes(blueprint, types) + rows: List[EntryRow] = [] + submodule_paths: set = set() + for path in sorted(blueprint.effective, key=lambda entry: entry.split(".")): + segments = path.split(".") + for depth in range(1, len(segments)): + submodule = ".".join(segments[:depth]) + if submodule in submodule_paths: + continue + submodule_paths.add(submodule) + rows.append( + EntryRow( + path=submodule, + kind="submodule", + nested_type=_nested_type_at(blueprint, types, submodule), + ) + ) + spec = blueprint.effective[path] + from_type = spec["from_type"] + own = from_type == type_name + if own: + entry = blueprint.parameters.get(path, {}) + default = entry.get("default") + target = entry.get("target") + if target is not None and instrument_name: + target = relative_path(target, instrument_name) + else: + at = at_by_path.get(path, "") + relative = path[len(at) + 1:] if at else path + defining = types.get(from_type) + default = ( + defining.parameters.get(relative, {}).get("default") + if defining is not None + else None + ) + target = None + rows.append( + EntryRow( + path=path, + kind="entry", + unit=spec["unit"], + own=own, + from_type=from_type, + default=default, + target=target, + ) + ) + return rows + + +def instances_of_type( + type_name: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> List[str]: + """Paths (relative to the Parameter Manager) of every Instance of the + Type ``type_name``, computed client-side over the model's parameter + rows (plan task 5.5; the mock's ``instancesOf``): the same candidate + rules :func:`compute_claims` matches by — every proper dotted prefix, + never the root, never anything under Globals — carrying every + effective path with the declared unit (D12). An empty Type has no + Instances. The Instances are sorted, for a stable pane order.""" + blueprint = types.get(type_name) + if blueprint is None: + return [] + effective = blueprint.effective + if not effective: + return [] + return [ + candidate + for candidate in _instance_candidates(parameters) + if _carries_effective_set(candidate, effective, parameters) + ] + + +def also_types( + instance: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> List[str]: + """Every Type the submodule ``instance`` is an Instance of (plan task + 5.5; the mock's ``also`` cell), in ``types`` order. The Types pane + shows the ones besides the selected Type as ``also , ``.""" + return [ + type_name + for type_name in types + if instance in instances_of_type(type_name, types, parameters) + ] + + +def parse_default_text(text: str) -> Any: + """The value a default line edit's text stands for: ``None`` when the + text is empty, otherwise the text parsed with ``ast.literal_eval``, + falling back to the raw string when it does not parse.""" + if text.strip() == "": + return None + try: + return ast.literal_eval(text) + except (ValueError, SyntaxError): + return text + + +#: Fixed pixel widths of the entries pane's unit, "locked to" and default +#: columns (the mock's 60/200/252 trio). +ENTRIES_UNIT_WIDTH = 60 +ENTRIES_LOCK_WIDTH = 210 +ENTRIES_DEFAULT_WIDTH = 250 + +#: Fixed pixel widths of the instances pane's parameter-count, "also" and +#: button columns (the mock's 150/160/110 trio). +INSTANCES_COUNT_WIDTH = 110 +INSTANCES_ALSO_WIDTH = 160 +INSTANCES_BUTTON_WIDTH = 80 + + +class TypesPane(QtWidgets.QWidget): + """The Types tab (plan task 5.5; the mock's Types view): three panes + around the selected Type. + + Left: the list of Types — name, number of Instances and number of + effective parameters, each row tinted with the Type's colour — and the + New type strip. Right, above: the entries of the selected Type as a + tree. Own entries carry an editable default (Return or the set button + commits), a Remove button and the Type Lock toggle in the "locked to" + column, with a re-target button and the Target's path while locked. + Entries from Nested Types render read-only with "defined by ". + Submodule rows show ``type: `` in the "locked to" column and, for + the selected Type's own Nested Types, a Remove button. Beneath the + tree run the "Add to type" and "Nested type" strips and a note line. + Right, below: the Instances of the selected Type — name, parameter + count, the other Types the Instance also carries and a Show button — + with the New instance strip and a note line. + + The pane never talks to the Server: every action is emitted as a + signal — ``addTypeRequested``, ``addEntryRequested``, + ``removeEntryRequested``, ``setDefaultRequested``, + ``toggleTypeLockRequested``, ``retargetTypeLockRequested``, + ``addNestedRequested``, ``removeNestedRequested``, + ``addInstanceRequested`` and ``showInstanceRequested`` — and + :class:`ParameterManagerGui`, which owns the pane, performs it and + reports errors and skipped Locks on the pane's note labels. + """ + + #: Signal(str) + #: Emitted when the user presses the New type strip's Add button; + #: the name is trimmed and not empty. + addTypeRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the selected Type changes (a row click, or a rebuild + #: that had to pick one). + typeSelected = QtCore.Signal(str) + + #: Signal(str, str, str, str) + #: Emitted when the user presses "Add to type": the Type's name, the + #: entry path, the default text and the unit. + addEntryRequested = QtCore.Signal(str, str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses an own entry's Remove button: the + #: Type's name and the entry path. + removeEntryRequested = QtCore.Signal(str, str) + + #: Signal(str, str, str) + #: Emitted when the user commits an own entry's default editor + #: (Return or the set button): the Type's name, the entry path and + #: the editor's text. + setDefaultRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses a Type Lock toggle: the Type's name + #: and the entry path. The GUI locks or unlocks from the entry's + #: stored Target. + toggleTypeLockRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses a locked entry's re-target button: + #: the Type's name and the entry path. + retargetTypeLockRequested = QtCore.Signal(str, str) + + #: Signal(str, str, str) + #: Emitted when the user presses "Add nested type": the Type's name, + #: the submodule and the Nested Type's name. + addNestedRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses an own Nested Type's Remove button: + #: the Type's name and the submodule. + removeNestedRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses the New instance strip's button: the + #: Type's name and the Instance's name. + addInstanceRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses an instance row's Show button: the + #: Type's name and the Instance's name. + showInstanceRequested = QtCore.Signal(str, str) + + def __init__( + self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None + ) -> None: + super().__init__(parent) + self.instrument_name = instrument_name + + # the selected Type, and one requested while the Server call that + # creates it is still in flight (honoured on the next rebuild) + self.selectedType: Optional[str] = None + self.requestedType: Optional[str] = None + # guards the selection slot against the rebuild's own index changes + self._building = False + + # the widgets of the entries rows, keyed by row path; and the Show + # buttons of the instance rows, keyed by instance path + self.entryWidgets: Dict[str, Dict[str, Any]] = {} + self.showButtons: Dict[str, QtWidgets.QPushButton] = {} + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal, self) + + # -- left: the list of Types and the New type strip + typeListPane = QtWidgets.QWidget(self.splitter) + typeListLayout = QtWidgets.QVBoxLayout(typeListPane) + typeListLayout.setContentsMargins(0, 0, 0, 0) + + self.typeModel = QtGui.QStandardItemModel(0, 3, self) + self.typeModel.setHorizontalHeaderLabels(["type", "instances", "parameters"]) + self.typeList = QtWidgets.QTreeView(typeListPane) + self.typeList.setModel(self.typeModel) + self.typeList.setRootIsDecorated(False) + self.typeList.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.typeList.setAlternatingRowColors(True) + typeHeader = self.typeList.header() + typeHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + for column, width in ((1, 70), (2, 90)): + typeHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + typeHeader.resizeSection(column, width) + + typeStrip = QtWidgets.QHBoxLayout() + typeStrip.setContentsMargins(0, 0, 0, 0) + typeStrip.addWidget(QtWidgets.QLabel("New type:")) + self.newTypeEdit = QtWidgets.QLineEdit(typeListPane) + self.newTypeEdit.setPlaceholderText("cavity") + self.addTypeButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), " Add" + ) + keepSmallHorizontally(self.addTypeButton) + typeStrip.addWidget(self.newTypeEdit, 1) + typeStrip.addWidget(self.addTypeButton) + self.typeNote = QtWidgets.QLabel(typeListPane) + + typeListLayout.addWidget(self.typeList, 1) + typeListLayout.addLayout(typeStrip) + typeListLayout.addWidget(self.typeNote) + + # -- right: the entries pane above the instances pane + rightPane = QtWidgets.QSplitter( + QtCore.Qt.Orientation.Vertical, self.splitter + ) + + entriesPane = QtWidgets.QWidget(rightPane) + entriesLayout = QtWidgets.QVBoxLayout(entriesPane) + entriesLayout.setContentsMargins(0, 0, 0, 0) + + self.entriesLabel = QtWidgets.QLabel(entriesPane) + self.entriesModel = QtGui.QStandardItemModel(0, 4, self) + self.entriesModel.setHorizontalHeaderLabels( + ["parameter", "unit", "locked to", "default"] + ) + self.entriesView = QtWidgets.QTreeView(entriesPane) + self.entriesView.setModel(self.entriesModel) + self.entriesView.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.entriesView.setAlternatingRowColors(True) + entriesHeader = self.entriesView.header() + entriesHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + for column, width in ( + (1, ENTRIES_UNIT_WIDTH), + (2, ENTRIES_LOCK_WIDTH), + (3, ENTRIES_DEFAULT_WIDTH), + ): + entriesHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + entriesHeader.resizeSection(column, width) + + entryStrip = QtWidgets.QHBoxLayout() + entryStrip.setContentsMargins(0, 0, 0, 0) + entryStrip.addWidget(QtWidgets.QLabel("Name:")) + self.entryNameEdit = QtWidgets.QLineEdit(entriesPane) + self.entryNameEdit.setPlaceholderText("pulses.pi.drag_multiplier") + entryStrip.addWidget(self.entryNameEdit, 2) + entryStrip.addWidget(QtWidgets.QLabel("Default:")) + self.entryDefaultEdit = QtWidgets.QLineEdit(entriesPane) + entryStrip.addWidget(self.entryDefaultEdit, 1) + entryStrip.addWidget(QtWidgets.QLabel("Unit:")) + self.entryUnitEdit = QtWidgets.QLineEdit(entriesPane) + entryStrip.addWidget(self.entryUnitEdit, 1) + self.addEntryButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add to type" + ) + keepSmallHorizontally(self.addEntryButton) + entryStrip.addWidget(self.addEntryButton) + + nestedStrip = QtWidgets.QHBoxLayout() + nestedStrip.setContentsMargins(0, 0, 0, 0) + nestedStrip.addWidget(QtWidgets.QLabel("Nested type:")) + self.nestedTypeCombo = QtWidgets.QComboBox(entriesPane) + nestedStrip.addWidget(self.nestedTypeCombo, 2) + nestedStrip.addWidget(QtWidgets.QLabel("at:")) + self.nestedAtEdit = QtWidgets.QLineEdit(entriesPane) + self.nestedAtEdit.setPlaceholderText("readout") + nestedStrip.addWidget(self.nestedAtEdit, 1) + self.addNestedButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add nested type" + ) + self.addNestedButton.setToolTip("require another Type at that submodule") + keepSmallHorizontally(self.addNestedButton) + nestedStrip.addWidget(self.addNestedButton) + + self.entriesNote = QtWidgets.QLabel(entriesPane) + self.entriesNote.setWordWrap(True) + + entriesLayout.addWidget(self.entriesLabel) + entriesLayout.addWidget(self.entriesView, 1) + entriesLayout.addLayout(entryStrip) + entriesLayout.addLayout(nestedStrip) + entriesLayout.addWidget(self.entriesNote) + + instancesPane = QtWidgets.QWidget(rightPane) + instancesLayout = QtWidgets.QVBoxLayout(instancesPane) + instancesLayout.setContentsMargins(0, 0, 0, 0) + + self.instancesLabel = QtWidgets.QLabel(instancesPane) + self.instancesModel = QtGui.QStandardItemModel(0, 4, self) + self.instancesModel.setHorizontalHeaderLabels( + ["instance", "parameters", "also", ""] + ) + self.instancesView = QtWidgets.QTreeView(instancesPane) + self.instancesView.setModel(self.instancesModel) + self.instancesView.setRootIsDecorated(False) + self.instancesView.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.instancesView.setAlternatingRowColors(True) + instancesHeader = self.instancesView.header() + instancesHeader.setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + for column, width in ( + (1, INSTANCES_COUNT_WIDTH), + (2, INSTANCES_ALSO_WIDTH), + (3, INSTANCES_BUTTON_WIDTH), + ): + instancesHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + instancesHeader.resizeSection(column, width) + + instanceStrip = QtWidgets.QHBoxLayout() + instanceStrip.setContentsMargins(0, 0, 0, 0) + instanceStrip.addWidget(QtWidgets.QLabel("New instance:")) + self.newInstanceEdit = QtWidgets.QLineEdit(instancesPane) + self.newInstanceEdit.setPlaceholderText("q04") + instanceStrip.addWidget(self.newInstanceEdit, 1) + self.addInstanceButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add instance" + ) + keepSmallHorizontally(self.addInstanceButton) + instanceStrip.addWidget(self.addInstanceButton) + self.instancesNote = QtWidgets.QLabel(instancesPane) + + instancesLayout.addWidget(self.instancesLabel) + instancesLayout.addWidget(self.instancesView, 1) + instancesLayout.addLayout(instanceStrip) + instancesLayout.addWidget(self.instancesNote) + + rightPane.addWidget(entriesPane) + rightPane.addWidget(instancesPane) + rightPane.setStretchFactor(0, 3) + rightPane.setStretchFactor(1, 2) + self.splitter.addWidget(typeListPane) + self.splitter.addWidget(rightPane) + self.splitter.setStretchFactor(0, 2) + self.splitter.setStretchFactor(1, 5) + layout.addWidget(self.splitter) + self.setLayout(layout) + + self.addTypeButton.clicked.connect(self._request_add_type) + self.newTypeEdit.returnPressed.connect(self.addTypeButton.click) + self.addEntryButton.clicked.connect(self._request_add_entry) + for edit in (self.entryNameEdit, self.entryDefaultEdit, self.entryUnitEdit): + edit.returnPressed.connect(self.addEntryButton.click) + self.addNestedButton.clicked.connect(self._request_add_nested) + self.nestedAtEdit.returnPressed.connect(self.addNestedButton.click) + self.addInstanceButton.clicked.connect(self._request_add_instance) + self.newInstanceEdit.returnPressed.connect(self.addInstanceButton.click) + self.typeList.selectionModel().currentChanged.connect(self._on_type_selected) + + # ------------------------------------------------------------------ + # rebuilds (plan task 5.5, readings 2-4, 7-8) + # ------------------------------------------------------------------ + + def select_type(self, name: str) -> None: + """Request the selection of the Type ``name``: honoured on the + next rebuild, once the Type is in the state the pane rebuilds + from. Used after the Server call that creates the Type.""" + self.requestedType = name + + def rebuild( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + """Rebuild the three panes from the client-side state (plan task + 5.5): the Type list from ``types`` with Instances counted over + ``parameters``, the entries and Instances panes from the selected + Type, and every row tinted with ``palette``.""" + self._rebuild_type_list(types, parameters, palette) + self._rebuild_selected_panes(types, parameters, palette) + + def _rebuild_type_list( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + names = list(types) + selection_changed = False + if self.requestedType is not None and self.requestedType in types: + selection_changed = self.selectedType != self.requestedType + self.selectedType = self.requestedType + self.requestedType = None + elif self.selectedType not in types: + # the first Type is selected when none is; the selection is + # dropped when the Type is gone + selection_changed = self.selectedType != (names[0] if names else None) + self.selectedType = names[0] if names else None + self.typeModel.removeRows(0, self.typeModel.rowCount()) + current_row = -1 + for row, name in enumerate(names): + count = len(instances_of_type(name, types, parameters)) + name_item = QtGui.QStandardItem(name) + instances_item = QtGui.QStandardItem(str(count)) + params_item = QtGui.QStandardItem(str(len(types[name].effective))) + self.typeModel.appendRow([name_item, instances_item, params_item]) + colours = palette.colours(name) + if colours is not None: + for item in (name_item, instances_item, params_item): + item.setData( + colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole + ) + if name == self.selectedType: + current_row = row + self._building = True + if current_row >= 0: + self.typeList.setCurrentIndex(self.typeModel.index(current_row, 0)) + else: + self.typeList.setCurrentIndex(QtCore.QModelIndex()) + self._building = False + if selection_changed and self.selectedType is not None: + self.typeSelected.emit(self.selectedType) + + def _rebuild_selected_panes( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + selected = self.selectedType or "" + self.entriesLabel.setText(f"parameters of {selected}") + self.instancesLabel.setText(f"instances of {selected}") + self._rebuild_nested_combo(selected, types) + self._rebuild_entries(selected, types, palette) + self._rebuild_instances(selected, types, parameters, palette) + + def _rebuild_nested_combo( + self, selected: str, types: Mapping[str, PMTypeBluePrint] + ) -> None: + self.nestedTypeCombo.clear() + self.nestedTypeCombo.addItems( + sorted(name for name in types if name != selected) + ) + + def _clear_index_widgets( + self, parent: Optional[QtGui.QStandardItem] = None + ) -> None: + """Delete the row widgets the entries view still hosts, so a + rebuild does not leave the old ones behind.""" + if parent is None: + parent = self.entriesModel.invisibleRootItem() + for row in range(parent.rowCount()): + for column in range(parent.columnCount()): + child = parent.child(row, column) + if child is None: + continue + widget = self.entriesView.indexWidget( + self.entriesModel.indexFromItem(child) + ) + if widget is not None: + widget.deleteLater() + first = parent.child(row, 0) + if first is not None and first.hasChildren(): + self._clear_index_widgets(first) + + def _entry_tint_type( + self, rows: List[EntryRow], index: int, selected: str + ) -> str: + """The Type whose tint an entries row shows: an entry row its + defining Type, a Nested Type row the Type required there, and a + structural submodule row the defining Type of the first entry + below it (the selected Type when that entry is own; the mock's + ``tintsFor(inc ? inc.type : (p.from || selType))``).""" + row = rows[index] + if row.kind == "entry": + return row.from_type or selected + if row.nested_type is not None: + return row.nested_type + for later in rows[index + 1:]: + if later.kind == "entry": + return later.from_type or selected + return selected + + def _rebuild_entries( + self, selected: str, types: Mapping[str, PMTypeBluePrint], palette: TypePalette + ) -> None: + self._clear_index_widgets() + self.entriesModel.removeRows(0, self.entriesModel.rowCount()) + self.entryWidgets = {} + blueprint = types.get(selected) + if selected is None or blueprint is None: + self.entriesView.expandAll() + return + rows = type_entry_rows(selected, types, self.instrument_name) + items_by_path: Dict[str, QtGui.QStandardItem] = {} + for index, row in enumerate(rows): + path = row.path + parent_item = ( + items_by_path[path.rsplit(".", 1)[0]] + if "." in path + else self.entriesModel.invisibleRootItem() + ) + colours = palette.colours(self._entry_tint_type(rows, index, selected)) + name_item = QtGui.QStandardItem(path.split(".")[-1]) + unit_item = QtGui.QStandardItem("" if row.kind == "submodule" else row.unit) + lock_item = QtGui.QStandardItem() + default_item = QtGui.QStandardItem() + parent_item.appendRow([name_item, unit_item, lock_item, default_item]) + items_by_path[path] = name_item + if colours is not None: + for item in (name_item, unit_item, lock_item, default_item): + item.setData( + colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole + ) + entry: Dict[str, Any] = { + "editor": None, + "set": None, + "remove": None, + "toggle": None, + "retarget": None, + "targetLabel": None, + "definedBy": None, + "removeNested": None, + } + self.entryWidgets[path] = entry + if row.kind == "submodule": + self._build_submodule_row( + selected, blueprint, row, lock_item, default_item, entry + ) + else: + self._build_entry_row( + selected, row, lock_item, default_item, entry + ) + self.entriesView.expandAll() + + def _build_submodule_row( + self, + selected: str, + blueprint: PMTypeBluePrint, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + if row.nested_type is not None: + lock_item.setText(f"type: {row.nested_type}") + if row.path in blueprint.nested: + # only the selected Type's OWN Nested Types are removable + nested = blueprint.nested[row.path] + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=self.entriesView.viewport() + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip( + f"stop requiring {nested} here — Instances keep the parameters" + ) + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda type_name=selected, submodule=row.path: self.removeNestedRequested.emit( + type_name, submodule + ) + ) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), remove + ) + entry["removeNested"] = remove + + def _build_entry_row( + self, + selected: str, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + if row.own: + self._build_own_entry_cells(selected, row, lock_item, default_item, entry) + else: + self._build_nested_entry_cells(selected, row, default_item, entry) + + def _build_own_entry_cells( + self, + selected: str, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + # the "locked to" column: the Type Lock toggle, and while the entry + # is locked the re-target button and the Target's relative path + locked = row.target is not None + lock_container = QtWidgets.QWidget(self.entriesView.viewport()) + lock_layout = QtWidgets.QHBoxLayout(lock_container) + lock_layout.setContentsMargins(0, 0, 0, 0) + toggle = make_lock_button(lock_container, locked) + if locked: + toggle.setToolTip( + f"locked to {row.target} — unlock and every Instance of " + f"{selected} goes back to its own value" + ) + else: + toggle.setToolTip( + f"lock — _globals.{selected}.{row.path} is created to hold the " + f"value, and every Instance of {selected} follows it" + ) + toggle.pressed.connect( + lambda type_name=selected, path=row.path: self.toggleTypeLockRequested.emit( + type_name, path + ) + ) + lock_layout.addWidget(toggle) + entry["toggle"] = toggle + if locked: + retarget = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/set.svg"), "", parent=lock_container + ) + retarget.setToolTip( + f"lock every Instance of {selected} to another Target — " + "pick one in the parameter tree" + ) + keepSmallHorizontally(retarget) + retarget.pressed.connect( + lambda type_name=selected, path=row.path: self.retargetTypeLockRequested.emit( + type_name, path + ) + ) + lock_layout.addWidget(retarget) + entry["retarget"] = retarget + target_label = QtWidgets.QLabel(row.target, parent=lock_container) + target_label.setToolTip( + f"{row.target} — followed by every Instance of {selected}" + ) + lock_layout.addWidget(target_label, 1) + entry["targetLabel"] = target_label + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(lock_item), lock_container + ) + + # the default column: the editable default with its set button and + # the entry's Remove button + editor_container = QtWidgets.QWidget(self.entriesView.viewport()) + editor_layout = QtWidgets.QHBoxLayout(editor_container) + editor_layout.setContentsMargins(0, 0, 0, 0) + editor = QtWidgets.QLineEdit(editor_container) + editor.setText("" if row.default is None else str(row.default)) + editor.setPlaceholderText("no default") + set_button = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/set.svg"), "", parent=editor_container + ) + keepSmallHorizontally(set_button) + set_button.pressed.connect( + lambda: self.setDefaultRequested.emit( + selected, row.path, editor.text() + ) + ) + editor.returnPressed.connect(set_button.click) + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=editor_container + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip( + "remove from the Type only — Instances keep the parameter " + "and lose the Type tint" + ) + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda type_name=selected, path=row.path: self.removeEntryRequested.emit( + type_name, path + ) + ) + editor_layout.addWidget(editor, 1) + editor_layout.addWidget(set_button) + editor_layout.addWidget(remove) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), editor_container + ) + entry["editor"] = editor + entry["set"] = set_button + entry["remove"] = remove + + def _build_nested_entry_cells( + self, + selected: str, + row: EntryRow, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + # read-only default text, and "defined by " where the Remove + # button of an own entry would sit + container = QtWidgets.QWidget(self.entriesView.viewport()) + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + default_label = QtWidgets.QLabel( + "" if row.default is None else str(row.default), parent=container + ) + layout.addWidget(default_label, 1) + defined_by = QtWidgets.QLabel(f"defined by {row.from_type}", parent=container) + defined_by.setToolTip( + f"defined by {row.from_type} — change the default there" + ) + layout.addWidget(defined_by) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), container + ) + entry["definedBy"] = defined_by + + def _rebuild_instances( + self, + selected: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + self.instancesModel.removeRows(0, self.instancesModel.rowCount()) + self.showButtons = {} + if not selected: + return + colours = palette.colours(selected) + for instance in instances_of_type(selected, types, parameters): + count = sum( + 1 for path in parameters if path.startswith(f"{instance}.") + ) + also = [ + type_name + for type_name in also_types(instance, types, parameters) + if type_name != selected + ] + name_item = QtGui.QStandardItem(instance) + count_item = QtGui.QStandardItem(f"{count} parameters") + also_item = QtGui.QStandardItem( + f"also {', '.join(also)}" if also else "" + ) + button_item = QtGui.QStandardItem() + self.instancesModel.appendRow( + [name_item, count_item, also_item, button_item] + ) + if colours is not None: + for item in (name_item, count_item, also_item, button_item): + item.setData( + colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole + ) + show = QtWidgets.QPushButton( + "Show", parent=self.instancesView.viewport() + ) + show.setToolTip("show in the parameter tree") + show.pressed.connect( + lambda type_name=selected, node=instance: self.showInstanceRequested.emit( + type_name, node + ) + ) + self.instancesView.setIndexWidget( + self.instancesModel.indexFromItem(button_item), show + ) + self.showButtons[instance] = show + + # ------------------------------------------------------------------ + # selection and strip requests (plan task 5.5, readings 3 and 5) + # ------------------------------------------------------------------ + + @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) + def _on_type_selected( + self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex + ) -> None: + """A row click selects the Type for the other two panes.""" + if self._building or not current.isValid(): + return + name = self.typeModel.item(current.row(), 0) + if name is None or name.text() == self.selectedType: + return + self.selectedType = name.text() + self.requestedType = None + self.typeSelected.emit(name.text()) + + def refresh_selected_panes( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + """Rebuild only the entries and Instances panes, keeping the Type + list as it is: the slot of a user selection.""" + self._rebuild_selected_panes(types, parameters, palette) + + @QtCore.Slot() + def _request_add_type(self) -> None: + name = self.newTypeEdit.text().strip() + if not name: + self.show_type_error("Name must not be empty.") + return + self.addTypeRequested.emit(name) + + @QtCore.Slot() + def _request_add_entry(self) -> None: + path = self.entryNameEdit.text().strip() + if not path: + self.show_entries_error("Name must not be empty.") + return + if self.selectedType is None: + return + self.addEntryRequested.emit( + self.selectedType, + path, + self.entryDefaultEdit.text(), + self.entryUnitEdit.text(), + ) + + @QtCore.Slot() + def _request_add_nested(self) -> None: + submodule = self.nestedAtEdit.text().strip() + if not submodule: + self.show_entries_error("Submodule must not be empty.") + return + if self.selectedType is None: + return + self.addNestedRequested.emit( + self.selectedType, submodule, self.nestedTypeCombo.currentText() + ) + + @QtCore.Slot() + def _request_add_instance(self) -> None: + name = self.newInstanceEdit.text().strip() + if not name: + self.show_instances_error("Name must not be empty.") + return + if self.selectedType is None: + return + self.addInstanceRequested.emit(self.selectedType, name) + + # ------------------------------------------------------------------ + # note lines (plan task 5.5, reading 5) + # ------------------------------------------------------------------ + + def show_type_error(self, text: str) -> None: + """Show an action error in red on the New type strip's note.""" + self.typeNote.setStyleSheet("QLabel { color: red }") + self.typeNote.setText(text) + + def reset_type_note(self) -> None: + """Restore the New type strip's default note (empty).""" + self.typeNote.setStyleSheet("") + self.typeNote.setText("") + + def show_entries_error(self, text: str) -> None: + """Show an action error in red on the entries pane's note.""" + self.entriesNote.setStyleSheet("QLabel { color: red }") + self.entriesNote.setText(text) + + def show_entries_note(self, text: str) -> None: + """Show ``text`` on the entries pane's note in the normal colour + (a skipped-Lock warning, for example).""" + self.entriesNote.setStyleSheet("") + self.entriesNote.setText(text) + + def reset_entries_note(self) -> None: + """Restore the entries pane's default note (empty).""" + self.entriesNote.setStyleSheet("") + self.entriesNote.setText("") + + def show_instances_error(self, text: str) -> None: + """Show an action error in red on the instances pane's note.""" + self.instancesNote.setStyleSheet("QLabel { color: red }") + self.instancesNote.setText(text) + + def reset_instances_note(self) -> None: + """Restore the instances pane's default note (empty).""" + self.instancesNote.setStyleSheet("") + self.instancesNote.setText("") + + +# ----------------- Parameter Manager Types tab - Ending ------------------------------- + + class ParameterDeleteDelegate(ParameterDelegate): #: Signal(str) #: Emits the name of the parameter to be deleted when the user presses the delete button. @@ -2122,11 +3155,15 @@ def __init__( self.locksSplitter.setStretchFactor(1, 2) layout.insertWidget(view_index, self.locksSplitter) self.locksPanel.setVisible(False) - # The existing content becomes tab 0 of the tab widget; the Types - # tab stays an empty placeholder until its own task builds it. + # The existing content becomes tab 0 of the tab widget; tab 1 + # holds the Types pane (plan task 5.5). self.parametersTab = QtWidgets.QWidget(self) self.parametersTab.setLayout(self.layout()) self.typesTab = QtWidgets.QWidget(self) + typesTabLayout = QtWidgets.QVBoxLayout(self.typesTab) + typesTabLayout.setContentsMargins(0, 0, 0, 0) + self.typesPane = TypesPane(self.instrument.name, parent=self.typesTab) + typesTabLayout.addWidget(self.typesPane) self.tabs = QtWidgets.QTabWidget(self) self.tabs.addTab(self.parametersTab, "Parameters") self.tabs.addTab(self.typesTab, "Types") @@ -2135,8 +3172,11 @@ def __init__( outerLayout.addWidget(self.tabs) # The arm strip sits right under the toolbar and stays hidden until # a Lock's Target is being picked (plan task 5.3). The Follower the - # pick is armed for is kept here. + # pick is armed for is kept here, and — for the Types tab's Type + # Lock re-target (plan task 5.5) — the (Type, entry) pair the + # re-target is armed for. self.armed_follower: Optional[str] = None + self.armed_type_lock: Optional[Tuple[str, str]] = None self.armStrip = LockArmStrip(self.parametersTab) parametersLayout = self.parametersTab.layout() assert isinstance(parametersLayout, QtWidgets.QVBoxLayout) @@ -2191,6 +3231,21 @@ def connectSignals(self) -> None: self.view.selectionModel().currentChanged.connect( self._on_tree_current_changed ) + # the Types pane (plan task 5.5): its actions run through this GUI, + # and a selection change re-renders the two panes it drives + self.typesPane.typeSelected.connect(self._on_pane_type_selected) + self.typesPane.addTypeRequested.connect(self._on_pane_add_type) + self.typesPane.addEntryRequested.connect(self._on_pane_add_entry) + self.typesPane.removeEntryRequested.connect(self._on_pane_remove_entry) + self.typesPane.setDefaultRequested.connect(self._on_pane_set_default) + self.typesPane.toggleTypeLockRequested.connect( + self._on_pane_toggle_type_lock + ) + self.typesPane.retargetTypeLockRequested.connect(self.arm_type_lock) + self.typesPane.addNestedRequested.connect(self._on_pane_add_nested) + self.typesPane.removeNestedRequested.connect(self._on_pane_remove_nested) + self.typesPane.addInstanceRequested.connect(self._on_pane_add_instance) + self.typesPane.showInstanceRequested.connect(self._on_pane_show_instance) self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) self.shortcutManager.register("clear_add", self.addParam.clear, self) self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) @@ -2453,13 +3508,51 @@ def arm_lock(self, follower: str) -> None: parameters = self._model_parameters() claims = compute_claims(self.state.types, parameters) self.armed_follower = follower + self.armed_type_lock = None self.armStrip.arm(follower, rank_lock_targets(follower, parameters, claims)) + def arm_type_lock(self, type_name: str, path: str) -> None: + """Arm the target picker for the Type Lock of the entry ``path`` + of the Type ``type_name`` (plan task 5.5): the strip shows on the + Parameters tab with the entry named in its label, and the + candidates are every parameter row of the source model ranked + with the entry path as the relative path (the same leaf on any + Instance first). Arming while already armed re-arms for the new + entry.""" + self.armed_type_lock = (type_name, path) + self.armed_follower = None + self.tabs.setCurrentIndex(0) + parameters = self._model_parameters() + claims = compute_claims(self.state.types, parameters) + self.armStrip.arm( + f"type {type_name} \u00b7 {path}", + rank_lock_targets("", parameters, claims, arm_rel=path), + ) + def pick_lock_target(self, target: str) -> None: - """Pick ``target`` as the Target of the armed Follower's Lock. A - refused Lock — a cycle (D7) among them — shows the Server's error - text on the strip and stays armed so another target can be picked; - a successful Lock disarms the strip.""" + """Pick ``target`` as the Target of the armed pick — a Follower's + Lock (plan task 5.3) or, while a Type Lock re-target is armed, the + Type Lock declaration with ``target`` as its Target (plan task + 5.5). A refused Lock — a cycle (D7), a self-lock — shows the + Server's error text on the strip and stays armed so another + target can be picked; a successful Lock disarms the strip, and a + Type Lock declaration names the Instance parameters it skipped + (D17) on the entries pane's note.""" + if self.armed_type_lock is not None: + type_name, path = self.armed_type_lock + try: + skipped = self.instrument.lock_type_parameter( + type_name, path, target=target + ) + except Exception as exc: + self.armStrip.show_error(str(exc)) + else: + if skipped: + self.typesPane.show_entries_note( + f"skipped: {', '.join(skipped)}" + ) + self.cancel_arm() + return if self.armed_follower is None: return try: @@ -2470,8 +3563,10 @@ def pick_lock_target(self, target: str) -> None: self.cancel_arm() def cancel_arm(self) -> None: - """Disarm the target picker without picking anything.""" + """Disarm the target picker without picking anything (either kind + of pick: a Follower's Lock or a Type Lock's re-target).""" self.armed_follower = None + self.armed_type_lock = None self.armStrip.disarm() @QtCore.Slot(QtCore.QModelIndex) @@ -2479,7 +3574,7 @@ def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: """A row click while the pick is armed chooses that row's parameter as the Target (the mock's rowClick); a submodule click does nothing.""" - if self.armed_follower is None: + if self.armed_follower is None and self.armed_type_lock is None: return source_index = self.proxyModel.mapToSource(index) source_index = source_index.sibling(source_index.row(), 0) @@ -2616,11 +3711,13 @@ def apply_tints(self) -> None: Runs after the state was refreshed from the Parameter Manager (on a model reload), on every ``pm-type-update`` Broadcast, and after a parameter was created or removed by a Broadcast, since matching - depends on which parameters exist. + depends on which parameters exist. The Types pane rebuilds from + the same state at the end (plan task 5.5). """ self.typePalette.sync(self.state.types) claims = compute_claims(self.state.types, self._model_parameters()) self._apply_tints_to_rows(self.model.invisibleRootItem(), claims) + self.refresh_types_pane() def _model_parameters(self) -> Dict[str, str]: """Every parameter row of the source model as ``{path: unit}``.""" @@ -2680,6 +3777,202 @@ def _apply_tints_to_rows( if item.hasChildren(): self._apply_tints_to_rows(item, claims) + # ------------------------------------------------------------------ + # the Types pane (plan task 5.5) + # ------------------------------------------------------------------ + + @QtCore.Slot() + def refresh_types_pane(self) -> None: + """Rebuild the Types pane's three panes from the client-side state + (plan task 5.5): the Types, the model's parameter rows and the + tint palette. + + Runs at the end of :meth:`apply_tints` — so a refresh, a profile + load, a ``pm-type-update`` Broadcast and a structural Broadcast + all refresh it — and after every pane action's Server call + returns (the Broadcast arrives on top of that; a double rebuild + is fine). The pane keeps the selected Type across rebuilds and + drops the selection when the Type is gone.""" + self.typesPane.rebuild( + self.state.types, self._model_parameters(), self.typePalette + ) + + @QtCore.Slot(str) + def _on_pane_type_selected(self, name: str) -> None: + """The Types pane's selected Type changed: re-render the entries + and Instances panes for it.""" + self.typesPane.refresh_selected_panes( + self.state.types, self._model_parameters(), self.typePalette + ) + + @QtCore.Slot(str) + def _on_pane_add_type(self, name: str) -> None: + """The New type strip: create the Type. A refused creation shows + the Server's error text on the strip's note; on success the new + Type is selected once the pane rebuilds (the ``pm-type-update`` + Broadcast brings it into the state).""" + try: + self.instrument.add_type(name) + except Exception as exc: + self.typesPane.show_type_error(str(exc)) + else: + self.typesPane.reset_type_note() + self.typesPane.newTypeEdit.clear() + self.typesPane.select_type(name) + self.refresh_types_pane() + + @QtCore.Slot(str, str, str, str) + def _on_pane_add_entry( + self, type_name: str, path: str, default_text: str, unit: str + ) -> None: + """The "Add to type" strip: add the entry with its parsed default + (``None`` when the text is empty) and unit (D11, D13). A refused + edit shows the Server's error text on the entries pane's note.""" + try: + self.instrument.add_type_parameter( + type_name, path, default=parse_default_text(default_text), unit=unit + ) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.typesPane.entryNameEdit.clear() + self.typesPane.entryDefaultEdit.clear() + self.typesPane.entryUnitEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_remove_entry(self, type_name: str, path: str) -> None: + """An own entry's Remove button: remove the entry from the Type + only (D13) — the Instances keep the parameter. A refused removal + shows the Server's error text on the entries pane's note.""" + try: + self.instrument.remove_type_parameter(type_name, path) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str, str) + def _on_pane_set_default(self, type_name: str, path: str, text: str) -> None: + """An own entry's committed default editor (Return or the set + button): set the entry's default to the parsed text (D13). A + refused set shows the Server's error text on the entries pane's + note.""" + try: + self.instrument.set_type_parameter_default( + type_name, path, parse_default_text(text) + ) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_toggle_type_lock(self, type_name: str, path: str) -> None: + """An entry's Type Lock toggle: declare the Type Lock on the + default Globals Target while the entry has no Target, remove only + the rule while it has one (D17). A refused toggle shows the + Server's error text on the entries pane's note; the Instance + parameters a declaration skips (D17) are named on it.""" + blueprint = self.state.types.get(type_name) + target = None + if blueprint is not None: + target = blueprint.parameters.get(path, {}).get("target") + try: + if target is None: + skipped = self.instrument.lock_type_parameter(type_name, path) + else: + self.instrument.unlock_type_parameter(type_name, path) + skipped = [] + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + if skipped: + self.typesPane.show_entries_note(f"skipped: {', '.join(skipped)}") + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str, str) + def _on_pane_add_nested( + self, type_name: str, submodule: str, nested: str + ) -> None: + """The "Nested type" strip: require the Nested Type ``nested`` at + the submodule (D11, D13). A refused edit shows the Server's error + text on the entries pane's note.""" + try: + self.instrument.add_nested_type(type_name, submodule, nested) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.typesPane.nestedAtEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_remove_nested(self, type_name: str, submodule: str) -> None: + """An own Nested Type's Remove button: remove the requirement + (D13) — the Instances keep the parameters. A refused removal + shows the Server's error text on the entries pane's note.""" + try: + self.instrument.remove_nested_type(type_name, submodule) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_add_instance(self, type_name: str, name: str) -> None: + """The New instance strip: create the Instance ``name`` of the + Type (D14). A refused creation shows the Server's error text on + the instances pane's note.""" + try: + self.instrument.add_instance(type_name, name) + except Exception as exc: + self.typesPane.show_instances_error(str(exc)) + else: + self.typesPane.reset_instances_note() + self.typesPane.newInstanceEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_show_instance(self, type_name: str, instance: str) -> None: + """An instance row's Show button: switch to the Parameters tab, + clear the filter, expand the tree and select the Instance's first + parameter row — the first effective entry under it, the submodule + row as the fallback — scrolled into view.""" + self.tabs.setCurrentIndex(0) + self.lineEdit.clear() + self.view.expandAll() + blueprint = self.state.types.get(type_name) + candidates = [instance] + if blueprint is not None and blueprint.effective: + first = sorted(blueprint.effective, key=lambda entry: entry.split("."))[0] + candidates.insert(0, f"{instance}.{first}") + for path in candidates: + matches = self.model.findItems( + path, + cast( + "QtCore.Qt.MatchFlags", + QtCore.Qt.MatchFlag.MatchExactly + | QtCore.Qt.MatchFlag.MatchRecursive, + ), + 0, + ) + if not matches: + continue + proxy_index = self.proxyModel.mapFromSource( + self.model.indexFromItem(matches[0]) + ) + if proxy_index.isValid(): + self.view.setCurrentIndex(proxy_index) + self.view.scrollTo(proxy_index) + break + @QtCore.Slot() def loadFromFile(self, loadFile: Optional[str] = None) -> None: try: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index e5dd1ac..13511d6 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -1,7 +1,7 @@ """Client-side state and Broadcast handling of the Parameter Manager GUI (plan task 5.1), its tabs, tints and gutter bands (plan task 5.2), its -Lock column, lock toggle, context menu and arm strip (plan task 5.3), and -its Locks panel (plan task 5.4). +Lock column, lock toggle, context menu and arm strip (plan task 5.3), its +Locks panel (plan task 5.4), and its Types tab (plan task 5.5). The GUI keeps the Parameter Manager's Types and Locks in a ``PMState`` (``ParameterManagerGui.state``), filled from the Parameter Manager on @@ -18,21 +18,20 @@ Client's Target update, and the model reload path live. The 5.4 tests cover the pure Locks-panel row model without a Server, the toolbar action and splitter, and the panel's rows, remove and lock-all actions, value editor -and "Lock selection to…" flow live. - -Two shapes of the live path are deliberately avoided in these tests, both -pre-existing and outside this task's scope: - -- a parameter another Client creates while the GUI is open makes the - model's creation branch resolve it on the GUI's (stale) Proxy Instrument - blueprint, which raises. Every Type edit below therefore has no creation - side effect: the parameters (with the units the entries declare) exist - before the GUI is built, and the entries land on Instances that already - carry them; -- ``lock_type_parameter`` without an explicit Target creates the Globals - parameter ``_globals..``, whose ``parameter-creation`` - Broadcast hits the same branch. The Type Lock test therefore declares an - explicit Target. +and "Lock selection to…" flow live. The 5.5 tests cover the pure Types-pane +helpers (the entries rows, the client-side Instance matching, the "also" +Types and the Type Lock arm ranking) without a Server, the model's +parameter-creation branch (the 5.1 TEST_AUDIT trap, fixed in plan task 5.5 +by Marcos's decision as an exception to plan rule 6), and the Types tab's +three panes live: a Type and an Instance created through the widgets with +the Server state checked, entry and Nested Type edits, the Type Lock +toggle and its re-target through the arm strip, Show, "also" and the +error notes. + +With the creation branch fixed, parameters may be created while the GUI +is open, so the 5.5 tests drive everything through the widgets — and the +Globals-default Type Lock, whose declaration creates the +``_globals..`` parameter, is testable live too. """ import os @@ -62,13 +61,17 @@ ParameterManagerTreeView, PMState, TypePalette, + also_types, build_lock_rows, compute_claims, followers_reaching, + instances_of_type, lock_column_text, lock_root, + parse_default_text, rank_lock_targets, relative_path, + type_entry_rows, ) from instrumentserver.gui.parameters import ParameterWidget from instrumentserver.gui.shortcuts import KeyboardShortcutManager @@ -487,12 +490,14 @@ def test_refresh_all_refills_the_state_from_the_server( # --------------------------------------------------------------------------- -def _type_blueprint(name, entries, nested=None, registry=None): +def _type_blueprint(name, entries, nested=None, registry=None, defaults=None, targets=None): """A ``PMTypeBluePrint`` whose effective set is expanded the way ``params.py`` expands it: the Type's own entries carry itself as ``from_type``, and every Nested Type's effective set is mounted under the submodule that requires it, keeping the defining Type.""" nested = dict(nested or {}) + defaults = defaults or {} + targets = targets or {} effective = { path: {"unit": unit, "from_type": name} for path, unit in entries.items() } @@ -502,7 +507,11 @@ def _type_blueprint(name, entries, nested=None, registry=None): return PMTypeBluePrint( name=name, parameters={ - path: {"default": None, "unit": unit, "target": None} + path: { + "default": defaults.get(path), + "unit": unit, + "target": targets.get(path), + } for path, unit in entries.items() }, nested=nested, @@ -685,7 +694,7 @@ def test_palette_keeps_the_slot_of_an_existing_type_across_updates(): def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): """The existing widget becomes tab 0 ("Parameters") of a QTabWidget; - tab 1 ("Types") is an empty placeholder for its own task; the gutter + tab 1 ("Types") hosts the Types pane (plan task 5.5); the gutter column is wired into the view: visual position 0, fixed width, its own delegate reading the GUI's palette, tree branches on the name column.""" gui = _make_gui(qtbot, pm, server_port) @@ -698,7 +707,8 @@ def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): assert parameters_tab.isAncestorOf(gui.view) types_tab = gui.tabs.widget(1) assert types_tab is gui.typesTab - assert types_tab.findChildren(QtWidgets.QWidget) == [] + assert isinstance(gui.typesPane, QtWidgets.QWidget) + assert types_tab.isAncestorOf(gui.typesPane) header = gui.view.header() assert header.visualIndex(GUTTER_COLUMN) == 0 @@ -1118,9 +1128,9 @@ def test_parameter_widget_set_read_only(qtbot): def _make_live_parameters(pm): - """Create the 5.3 live tests' parameters before the GUI is built (a - parameter another Client creates while the GUI is open crashes the - model's parameter-creation branch, TEST_AUDIT.md).""" + """Create the 5.3 live tests' parameters before the GUI is built. + (Since the 5.5 creation-branch fix they could equally be created + while the GUI is open; the 5.3 tests keep their original order.)""" pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") pm.add_parameter("q02.IF", initial_value=2.0, unit="Hz") pm.add_parameter("q03.IF", initial_value=3.0, unit="Hz") @@ -2037,3 +2047,744 @@ def _unlocked_toggle_back(): assert gui.locksPanel.noteLabel.text() == LOCK_PANEL_NOTE finally: gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.5: the Types tab (and the parameter-creation branch fix) +# --------------------------------------------------------------------------- + + +def _row_exists(gui, path): + """Whether the parameters tree holds the row ``path`` (a soft check + for ``qtbot.waitUntil`` callbacks).""" + matches = gui.model.findItems( + path, + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + return bool(matches) + + +def _state_type_has(gui, type_name, path): + """Whether the GUI's state holds the Type ``type_name`` with the + entry ``path``.""" + blueprint = gui.state.types.get(type_name) + return blueprint is not None and path in blueprint.parameters + + +def _type_list_row(gui, name): + """The three items of the Types pane's type-list row ``name``, or + ``None`` while the row does not exist.""" + model = gui.typesPane.typeModel + matches = model.findItems(name, QtCore.Qt.MatchFlag.MatchExactly, 0) + if not matches: + return None + row = matches[0].row() + return [model.item(row, column) for column in range(3)] + + +def _entry_row_items(gui, path): + """The four items of the entries-pane row ``path`` (walked segment by + segment through the tree), or ``None`` while the row does not + exist.""" + item = gui.typesPane.entriesModel.invisibleRootItem() + for segment in path.split("."): + found = None + for row in range(item.rowCount()): + child = item.child(row, 0) + if child is not None and child.text() == segment: + found = child + break + if found is None: + return None + item = found + parent = item.parent() + if parent is None: + model = gui.typesPane.entriesModel + return [model.item(item.row(), column) for column in range(4)] + return [parent.child(item.row(), column) for column in range(4)] + + +def _instance_row_items(gui, name): + """The four items of the instances-pane row ``name``, or ``None`` + while the row does not exist.""" + model = gui.typesPane.instancesModel + matches = model.findItems(name, QtCore.Qt.MatchFlag.MatchExactly, 0) + if not matches: + return None + row = matches[0].row() + return [model.item(row, column) for column in range(4)] + + +def _create_type_with_instance(qtbot, gui, pm, type_name="qubit", instance="q10"): + """Create the Type ``type_name`` with one entry ``IF`` (default 1.0, + unit Hz) and the Instance ``instance`` through the Types pane's + widgets, waiting for the Server state and the pane rows each step.""" + gui.tabs.setCurrentIndex(1) + gui.typesPane.newTypeEdit.setText(type_name) + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: type_name in pm.list_types(), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == type_name + and _type_list_row(gui, type_name) is not None, + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.entryNameEdit.setText("IF") + gui.typesPane.entryDefaultEdit.setText("1.0") + gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: _state_type_has(gui, type_name, "IF"), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("editor") is not None, + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.newInstanceEdit.setText(instance) + gui.typesPane.addInstanceButton.click() + qtbot.waitUntil( + lambda: pm.has_param(f"{instance}.IF"), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: _instance_row_items(gui, instance) is not None, + timeout=BROADCAST_TIMEOUT, + ) + + +def test_type_entry_rows_build_the_segment_sorted_tree(): + """The entries rows of a Type with a Nested Type: segment-wise order + (the mock's sort), submodule rows naming the Nested Type they + require, own entries marked with their default and Target, nested + entries carrying the defining Type and its own default.""" + readout = _type_blueprint("readout", {"bw": "Hz"}, defaults={"bw": 2.0}) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + defaults={"IF": 1.0}, + targets={"IF": f"{PM_NAME}._globals.qubit.IF"}, + ) + rows = type_entry_rows( + "qubit", {"readout": readout, "qubit": qubit}, PM_NAME + ) + assert [row.path for row in rows] == ["IF", "readout", "readout.bw"] + assert rows[0].kind == "entry" and rows[0].own + assert rows[0].from_type == "qubit" + assert rows[0].unit == "Hz" and rows[0].default == 1.0 + assert rows[0].target == "_globals.qubit.IF" + assert rows[1].kind == "submodule" and rows[1].nested_type == "readout" + assert rows[2].kind == "entry" and not rows[2].own + assert rows[2].from_type == "readout" + assert rows[2].unit == "Hz" and rows[2].default == 2.0 + assert rows[2].target is None + + +def test_type_entry_rows_walks_deeper_nested_submodules(): + """A Nested Type's own Nested Type: the deeper submodule row names the + Type required there (the mock's ``at`` walk), and its entries carry + the defining Type's own default for the path relative to it; a + submodule with no Nested Type is a structural row.""" + pulse_window = _type_blueprint("pulse_window", {"win": "s"}, defaults={"win": 5}) + readout = _type_blueprint( + "readout", + {"bw": "Hz"}, + nested={"pw": "pulse_window"}, + registry={"pulse_window": pulse_window}, + ) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + ) + types = {"pulse_window": pulse_window, "readout": readout, "qubit": qubit} + rows = type_entry_rows("qubit", types) + assert [row.path for row in rows] == [ + "IF", + "readout", + "readout.bw", + "readout.pw", + "readout.pw.win", + ] + by_path = {row.path: row for row in rows} + assert by_path["readout"].nested_type == "readout" + assert by_path["readout.pw"].nested_type == "pulse_window" + assert by_path["readout.pw.win"].kind == "entry" + assert by_path["readout.pw.win"].from_type == "pulse_window" + assert by_path["readout.pw.win"].default == 5 + + deep = _type_blueprint("dq", {"short.win": "s"}) + rows = type_entry_rows("dq", {"dq": deep}) + assert [row.path for row in rows] == ["short", "short.win"] + assert rows[0].nested_type is None + + +def test_type_entry_rows_of_an_unknown_type(): + """An unknown Type yields no rows.""" + assert type_entry_rows("gone", {}) == [] + + +def test_instances_of_type_matches_like_compute_claims(): + """The client-side Instance matching follows the rules + ``compute_claims`` matches by: every effective path with the declared + unit, the root never, Globals never, an empty Type never, extra + parameters do not matter.""" + qubit = _type_blueprint("qubit", {"IF": "Hz", "bw": "Hz"}) + types = {"qubit": qubit} + parameters = { + "q10.IF": "Hz", + "q10.bw": "Hz", + "q10.gain": "dB", + "IF": "Hz", + "bw": "Hz", + "_globals.qubit.IF": "Hz", + "_globals.qubit.bw": "Hz", + "wrong.IF": "Hz", + "wrong.bw": "V", + } + assert instances_of_type("qubit", types, parameters) == ["q10"] + # one path missing or one unit off: no Instance + assert instances_of_type("qubit", types, {"q10.IF": "Hz"}) == [] + assert instances_of_type("qubit", types, {"q10.IF": "Hz", "q10.bw": "V"}) == [] + assert instances_of_type("empty", types, parameters) == [] + + +def test_also_types_lists_every_type_the_submodule_carries(): + """A submodule can be an Instance of several Types; ``also_types`` + lists them all in registry order (the Types pane filters the selected + one out when it composes the ``also`` text).""" + big = _type_blueprint("big", {"a": "Hz", "b": "Hz"}) + small = _type_blueprint("small", {"a": "Hz"}) + types = {"big": big, "small": small} + parameters = {"q01.a": "Hz", "q01.b": "Hz"} + assert also_types("q01", types, parameters) == ["big", "small"] + assert also_types("other", types, parameters) == [] + + +def test_parse_default_text(): + """An empty default text parses to ``None``, a literal to its value, + and anything else stays the raw string.""" + assert parse_default_text("") is None + assert parse_default_text(" ") is None + assert parse_default_text("1.0") == 1.0 + assert parse_default_text("3") == 3 + assert parse_default_text("True") is True + assert parse_default_text("abc") == "abc" + assert parse_default_text("'xy'") == "xy" + + +def test_rank_lock_targets_ranks_for_a_type_lock_arm(): + """With an explicit ``arm_rel`` — the Types tab's Type Lock re-target + — the entry path ranks the candidates: the same leaf on any Instance + first, then submodules on the way, everything else last. There is no + Follower to exclude.""" + claims = { + "q01.IF": Claim(type="qubit", instance="q01", stack=["qubit"]), + "q02.IF": Claim(type="qubit", instance="q02", stack=["qubit"]), + } + ranked = rank_lock_targets( + "", ["other.x", "q02.IF", "q05.IF.gain", "q01.IF"], claims, arm_rel="IF" + ) + assert ranked == ["q01.IF", "q02.IF", "q05.IF.gain", "other.x"] + + +def test_a_creation_from_a_second_client_under_an_existing_submodule_appears( + qtbot, pm, second_client, server_port +): + """Regression (plan task 5.5, reading 0): a parameter another Client + creates under an existing submodule while the GUI is open appears in + the model with its delegate widget.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("cr01.x", initial_value=1.0, unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_parameter("cr01.y", initial_value=2.0, unit="Hz") + qtbot.waitUntil( + lambda: _row_exists(gui, "cr01.y") + and "cr01.y" in gui.view.delegate.parameters, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_creation_from_a_second_client_in_a_new_submodule_appears( + qtbot, pm, second_client, server_port +): + """Regression: the same for a parameter that brings a new submodule + with it.""" + second_pm = _second_parameter_manager(second_client) + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_parameter("crnew.z", initial_value=3.0, unit="s") + qtbot.waitUntil( + lambda: _row_exists(gui, "crnew") + and _row_exists(gui, "crnew.z") + and "crnew.z" in gui.view.delegate.parameters, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_an_add_instance_from_a_second_client_appears_and_tints( + qtbot, pm, second_client, server_port +): + """Regression: an Instance a second Client creates while the GUI is + open appears row by row, with widgets, and the tints recompute so the + new Instance's rows carry the Type's tint.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_type("insttype") + second_pm.add_type_parameter("insttype", "ix", default=1.0, unit="Hz") + second_pm.add_type_parameter("insttype", "iy", default=2.0, unit="Hz") + qtbot.waitUntil( + lambda: _state_type_has(gui, "insttype", "iy"), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.add_instance("insttype", "instq") + qtbot.waitUntil( + lambda: _row_exists(gui, "instq.ix") + and _row_exists(gui, "instq.iy") + and "instq.ix" in gui.view.delegate.parameters + and "instq.iy" in gui.view.delegate.parameters, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: _type_tint(gui, "insttype") is not None + and _row_items(gui, "instq.ix")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "insttype") + and _row_items(gui, "instq.iy")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "insttype"), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_an_add_instance_from_the_gui_proxy_appears( + qtbot, pm, second_client, server_port +): + """Regression: the same for the GUI's own Proxy calling + ``add_instance`` — and the Proxy is refreshed by the creation branch, + so attribute access resolves the created parameters.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_type("owntype") + second_pm.add_type_parameter("owntype", "ox", default=1.0, unit="Hz") + second_pm.add_type_parameter("owntype", "oy", default=2.0, unit="Hz") + qtbot.waitUntil( + lambda: _state_type_has(gui, "owntype", "oy"), + timeout=BROADCAST_TIMEOUT, + ) + + pm.add_instance("owntype", "ownq") + qtbot.waitUntil( + lambda: _row_exists(gui, "ownq.ox") + and _row_exists(gui, "ownq.oy") + and "ownq.ox" in gui.view.delegate.parameters, + timeout=BROADCAST_TIMEOUT, + ) + assert pm.ownq.ox.get() == 1.0 + assert pm.ownq.oy.get() == 2.0 + qtbot.waitUntil( + lambda: _type_tint(gui, "owntype") is not None + and _row_items(gui, "ownq.ox")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "owntype"), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_the_types_tab_creates_a_type_and_an_instance( + qtbot, pm, second_client, server_port +): + """The plan's named flow: a Type and an Instance created through the + Types pane's widgets, with the Server state matching; the new + Instance's tree row carries the Type's tint; a second Client's entry + appears in the panes and the tree without any GUI action.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + _create_type_with_instance(qtbot, gui, pm) + + # the Server holds the entry with its default and unit + assert pm.get_type("qubit").parameters["IF"] == { + "default": 1.0, + "unit": "Hz", + "target": None, + } + # the type list shows the row, selected, with its counts + row = _type_list_row(gui, "qubit") + assert row is not None + current = gui.typesPane.typeModel.item( + gui.typesPane.typeList.currentIndex().row(), 0 + ) + assert current is not None and current.text() == "qubit" + assert gui.typesPane.selectedType == "qubit" + # the entry row is in the pane + assert _entry_row_items(gui, "IF") is not None + # the Instance was created with the entry's default and unit + qtbot.waitUntil(lambda: _row_exists(gui, "q10.IF"), timeout=BROADCAST_TIMEOUT) + assert pm.q10.IF.get() == 1.0 + assert pm.q10.IF.unit == "Hz" + # the instances row shows the parameter count + assert _instance_row_items(gui, "q10")[1].text() == "1 parameters" + # the Parameters tree shows the q10.IF row tinted with qubit's colour + qtbot.waitUntil( + lambda: _type_tint(gui, "qubit") is not None + and _row_items(gui, "q10.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit"), + timeout=BROADCAST_TIMEOUT, + ) + + # a second Client's entry appears without any GUI action + second_pm.add_type_parameter("qubit", "bw", default=2.0, unit="Hz") + qtbot.waitUntil( + lambda: _entry_row_items(gui, "bw") is not None + and _row_exists(gui, "q10.bw"), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_the_types_tab_edits_entries_and_nested_types( + qtbot, pm, second_client, server_port +): + """Entry edits through the widgets: the default editor sets the + entry's default on the Server, Remove takes the entry off the Type + while the Instance keeps the parameter (D13), and a Nested Type added + through the strips shows its submodule row and "defined by" entries + and creates the parameters on the Instances, until it is removed.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + + # change IF's default to 3.0 via the editor + set + entry = gui.typesPane.entryWidgets["IF"] + entry["editor"].setText("3.0") + entry["set"].click() + qtbot.waitUntil( + lambda: pm.get_type("qubit").parameters["IF"]["default"] == 3.0, + timeout=BROADCAST_TIMEOUT, + ) + + # a second entry via the strip, then Remove via the row button + gui.typesPane.entryNameEdit.setText("bw") + gui.typesPane.entryDefaultEdit.setText("2.0") + gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("bw", {}).get("remove") + is not None, + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_type("qubit").parameters["bw"]["default"] == 2.0 + gui.typesPane.entryWidgets["bw"]["remove"].click() + qtbot.waitUntil( + lambda: "bw" not in pm.get_type("qubit").parameters, + timeout=BROADCAST_TIMEOUT, + ) + # D13: the Instance keeps the parameter + assert pm.has_param("q10.bw") + + # nested: add Type "readout" with entry "bw" (Hz) through the + # widgets + gui.typesPane.newTypeEdit.setText("readout") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "readout", + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.entryNameEdit.setText("bw") + gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: "bw" in pm.get_type("readout").parameters, + timeout=BROADCAST_TIMEOUT, + ) + + # select "qubit" again through the type list, then nest readout + # at "readout" + row = _type_list_row(gui, "qubit") + assert row is not None + gui.typesPane.typeList.setCurrentIndex( + gui.typesPane.typeModel.indexFromItem(row[0]) + ) + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "qubit", + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.nestedTypeCombo.setCurrentText("readout") + gui.typesPane.nestedAtEdit.setText("readout") + gui.typesPane.addNestedButton.click() + qtbot.waitUntil( + lambda: pm.get_type("qubit").nested == {"readout": "readout"}, + timeout=BROADCAST_TIMEOUT, + ) + # the entries pane shows the submodule row with its Nested Type + # and the defined-by entry + qtbot.waitUntil( + lambda: _entry_row_items(gui, "readout") is not None + and _entry_row_items(gui, "readout.bw") is not None, + timeout=BROADCAST_TIMEOUT, + ) + assert _entry_row_items(gui, "readout")[2].text() == "type: readout" + defined_by = gui.typesPane.entryWidgets["readout.bw"]["definedBy"] + assert defined_by.text() == "defined by readout" + assert defined_by.toolTip() == "defined by readout — change the default there" + # the nested entry was created on the Instance + qtbot.waitUntil( + lambda: pm.has_param("q10.readout.bw"), timeout=BROADCAST_TIMEOUT + ) + + # Remove nested: only the requirement goes (D13) + gui.typesPane.entryWidgets["readout"]["removeNested"].click() + qtbot.waitUntil( + lambda: pm.get_type("qubit").nested == {}, timeout=BROADCAST_TIMEOUT + ) + assert pm.has_param("q10.readout.bw") + finally: + gui.model.stopListener() + + +def test_the_types_tab_type_locks_toggle_and_retarget( + qtbot, pm, second_client, server_port +): + """The Type Lock toggle puts the Globals default Target on the entry, + locks the Instance parameter and shows the ``_globals`` row in the + tree; toggling again removes only the rule (D17) and the Locks stay. + The re-target button arms the picker on the Parameters tab and the + picked row becomes the entry's Target.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + + globals_target = f"{PM_NAME}._globals.qubit.IF" + + def _state_target(): + blueprint = gui.state.types.get("qubit") + return None if blueprint is None else blueprint.parameters["IF"]["target"] + + # toggle on: the Globals default Target is created and locked + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: _state_target() == globals_target + and pm.get_type("qubit").parameters["IF"]["target"] == globals_target, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: pm.get_lock("q10.IF") + == PMLockBluePrint(target=globals_target, locked=True), + timeout=BROADCAST_TIMEOUT, + ) + # the _globals.qubit.IF row appears in the tree + qtbot.waitUntil( + lambda: _row_exists(gui, "_globals.qubit.IF"), + timeout=BROADCAST_TIMEOUT, + ) + + # toggle again: the rule goes, the Lock stays (D17); the wait runs + # on the state, so the next toggle reads the current rule and not + # the one still in flight + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: _state_target() is None + and pm.get_type("qubit").parameters["IF"]["target"] is None, + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("q10.IF") is not None + + # toggle on once more: the re-target button needs a locked entry + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: _state_target() == globals_target + and pm.get_type("qubit").parameters["IF"]["target"] == globals_target, + timeout=BROADCAST_TIMEOUT, + ) + + # re-target through the arm strip, with a root parameter created + # first + pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + qtbot.waitUntil(lambda: _row_exists(gui, "tshared"), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("retarget") + is not None, + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.entryWidgets["IF"]["retarget"].click() + assert gui.tabs.currentIndex() == 0 + assert gui.armStrip.label.text() == "Target for type qubit · IF" + assert gui.armed_type_lock == ("qubit", "IF") + assert gui.armed_follower is None + + gui.pick_lock_target("tshared") + qtbot.waitUntil( + lambda: pm.get_type("qubit").parameters["IF"]["target"] + == f"{PM_NAME}.tshared", + timeout=BROADCAST_TIMEOUT, + ) + assert gui.armStrip.isHidden() + assert gui.armed_type_lock is None + assert gui.armed_follower is None + finally: + gui.model.stopListener() + + +def test_the_types_tab_names_skipped_locks_on_the_note( + qtbot, pm, second_client, server_port +): + """A Type Lock declaration skips the Instance parameters already + locked to another Target (D17) and names them on the entries pane's + note; the skipped parameter keeps its own Target.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + + pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + qtbot.waitUntil(lambda: _row_exists(gui, "tshared"), timeout=BROADCAST_TIMEOUT) + pm.lock("q10.IF", "tshared") + qtbot.waitUntil( + lambda: gui.state.locks.get("q10.IF") + == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("toggle") + is not None, + timeout=BROADCAST_TIMEOUT, + ) + + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: "skipped: q10.IF" in gui.typesPane.entriesNote.text(), + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("q10.IF").target == f"{PM_NAME}.tshared" + finally: + gui.model.stopListener() + + +def test_the_types_tab_show_button_and_also_types( + qtbot, pm, second_client, server_port +): + """An instance row's Show button switches to the Parameters tab, + clears the filter and selects the Instance's first parameter row; a + second Type the Instance also carries shows as ``also ``.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + qtbot.waitUntil( + lambda: gui.typesPane.showButtons.get("q10") is not None, + timeout=BROADCAST_TIMEOUT, + ) + + # a second Type whose set q10 also carries + second_pm.add_type("smallq") + second_pm.add_type_parameter("smallq", "IF", unit="Hz") + qtbot.waitUntil( + lambda: _instance_row_items(gui, "q10") is not None + and _instance_row_items(gui, "q10")[2].text() == "also smallq", + timeout=BROADCAST_TIMEOUT, + ) + + # press q10's Show: tab 0, filter empty, the current row is q10.IF + gui.lineEdit.setText("no-such-parameter") + gui.typesPane.showButtons["q10"].click() + assert gui.tabs.currentIndex() == 0 + qtbot.waitUntil(lambda: gui.lineEdit.text() == "") + current = gui._getCurrentItem() + assert current is not None and current.name == "q10.IF" + finally: + gui.model.stopListener() + + +def test_the_types_tab_shows_server_errors_and_empty_names( + qtbot, pm, second_client, server_port +): + """The strips show the Server's refusal for a repeated name on their + note and their own note for an empty name.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + gui.tabs.setCurrentIndex(1) + gui.typesPane.newTypeEdit.setText("errtype") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: "errtype" in pm.list_types(), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "errtype", + timeout=BROADCAST_TIMEOUT, + ) + + # an existing name: the Server's text on the note + gui.typesPane.newTypeEdit.setText("errtype") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: "already exists" in gui.typesPane.typeNote.text(), + timeout=BROADCAST_TIMEOUT, + ) + + # an empty name: the strip's own note + gui.typesPane.newTypeEdit.setText("") + gui.typesPane.addTypeButton.click() + assert gui.typesPane.typeNote.text() == "Name must not be empty." + + # the New instance and "Add to type" strips refuse empty names too + gui.typesPane.newInstanceEdit.setText("") + gui.typesPane.addInstanceButton.click() + assert gui.typesPane.instancesNote.text() == "Name must not be empty." + gui.typesPane.entryNameEdit.setText("") + gui.typesPane.addEntryButton.click() + assert gui.typesPane.entriesNote.text() == "Name must not be empty." + finally: + gui.model.stopListener() From edceebc03dec2e19d74b6a4e51e03eaceb899647 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 15:29:32 -0500 Subject: [PATCH 081/107] 5.5: fix from review round 1: type-list count assertions, re-target failure and empty-submodule tests, nested-instance matching test, stripped unit --- src/instrumentserver/gui/instruments.py | 4 +- test/pytest/test_pm_gui.py | 125 ++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index e65c391..30fe086 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -2769,11 +2769,13 @@ def _request_add_entry(self) -> None: return if self.selectedType is None: return + # the unit is stripped: matching compares units exactly (D12), and + # a trailing space would make the Type match no Instance self.addEntryRequested.emit( self.selectedType, path, self.entryDefaultEdit.text(), - self.entryUnitEdit.text(), + self.entryUnitEdit.text().strip(), ) @QtCore.Slot() diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 13511d6..ab19276 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -2233,7 +2233,7 @@ def test_instances_of_type_matches_like_compute_claims(): unit, the root never, Globals never, an empty Type never, extra parameters do not matter.""" qubit = _type_blueprint("qubit", {"IF": "Hz", "bw": "Hz"}) - types = {"qubit": qubit} + types = {"qubit": qubit, "empty_type": _type_blueprint("empty_type", {})} parameters = { "q10.IF": "Hz", "q10.bw": "Hz", @@ -2249,9 +2249,34 @@ def test_instances_of_type_matches_like_compute_claims(): # one path missing or one unit off: no Instance assert instances_of_type("qubit", types, {"q10.IF": "Hz"}) == [] assert instances_of_type("qubit", types, {"q10.IF": "Hz", "q10.bw": "V"}) == [] + # a genuinely empty Type in the registry has no Instances (D12) + assert instances_of_type("empty_type", types, parameters) == [] assert instances_of_type("empty", types, parameters) == [] +def test_instances_of_type_with_a_nested_type(): + """The client-side matching expands Nested Types the way the Server's + ``instances_of`` does (D12): the submodule must carry the outer + Type's entries and the Nested Type's entries under their submodule, + each with the unit the declaring Type declares.""" + readout = _type_blueprint("readout", {"bw": "Hz"}) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + ) + types = {"qubit": qubit} + parameters = {"q10.IF": "Hz", "q10.readout.bw": "Hz"} + assert instances_of_type("qubit", types, parameters) == ["q10"] + # the nested entry's unit is off: no Instance + assert instances_of_type( + "qubit", types, {"q10.IF": "Hz", "q10.readout.bw": "V"} + ) == [] + # the nested entry is missing: no Instance + assert instances_of_type("qubit", types, {"q10.IF": "Hz"}) == [] + + def test_also_types_lists_every_type_the_submodule_carries(): """A submodule can be an Instance of several Types; ``also_types`` lists them all in registry order (the Types pane filters the selected @@ -2427,16 +2452,45 @@ def test_the_types_tab_creates_a_type_and_an_instance( qtbot, pm, second_client, server_port ): """The plan's named flow: a Type and an Instance created through the - Types pane's widgets, with the Server state matching; the new - Instance's tree row carries the Type's tint; a second Client's entry - appears in the panes and the tree without any GUI action.""" + Types pane's widgets, with the Server state matching and the type + list's Instances and parameter counts following; the new Instance's + tree row carries the Type's tint; a second Client's entry appears in + the panes and the tree without any GUI action.""" second_pm = _second_parameter_manager(second_client) gui = _make_gui(qtbot, pm, server_port) try: _wait_until_broadcasts_arrive(qtbot, gui, second_pm) - _create_type_with_instance(qtbot, gui, pm) + # create the Type through the widgets + gui.tabs.setCurrentIndex(1) + gui.typesPane.newTypeEdit.setText("qubit") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: "qubit" in pm.list_types(), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "qubit" + and _type_list_row(gui, "qubit") is not None, + timeout=BROADCAST_TIMEOUT, + ) + + # add the entry through the widgets + gui.typesPane.entryNameEdit.setText("IF") + gui.typesPane.entryDefaultEdit.setText("1.0") + gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: _state_type_has(gui, "qubit", "IF"), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("editor") + is not None, + timeout=BROADCAST_TIMEOUT, + ) + # the type list counts: one effective parameter, no Instances yet + assert _type_list_row(gui, "qubit")[2].text() == "1" + assert _type_list_row(gui, "qubit")[1].text() == "0" # the Server holds the entry with its default and unit assert pm.get_type("qubit").parameters["IF"] == { @@ -2454,6 +2508,22 @@ def test_the_types_tab_creates_a_type_and_an_instance( assert gui.typesPane.selectedType == "qubit" # the entry row is in the pane assert _entry_row_items(gui, "IF") is not None + + # create the Instance through the widgets + gui.typesPane.newInstanceEdit.setText("q10") + gui.typesPane.addInstanceButton.click() + qtbot.waitUntil( + lambda: pm.has_param("q10.IF"), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: _instance_row_items(gui, "q10") is not None, + timeout=BROADCAST_TIMEOUT, + ) + # the Instances count follows the created Instance + qtbot.waitUntil( + lambda: _type_list_row(gui, "qubit")[1].text() == "1", + timeout=BROADCAST_TIMEOUT, + ) # the Instance was created with the entry's default and unit qtbot.waitUntil(lambda: _row_exists(gui, "q10.IF"), timeout=BROADCAST_TIMEOUT) assert pm.q10.IF.get() == 1.0 @@ -2470,11 +2540,13 @@ def test_the_types_tab_creates_a_type_and_an_instance( timeout=BROADCAST_TIMEOUT, ) - # a second Client's entry appears without any GUI action + # a second Client's entry appears without any GUI action, and the + # effective parameter count follows it second_pm.add_type_parameter("qubit", "bw", default=2.0, unit="Hz") qtbot.waitUntil( lambda: _entry_row_items(gui, "bw") is not None - and _row_exists(gui, "q10.bw"), + and _row_exists(gui, "q10.bw") + and _type_list_row(gui, "qubit")[2].text() == "2", timeout=BROADCAST_TIMEOUT, ) finally: @@ -2505,10 +2577,12 @@ def test_the_types_tab_edits_entries_and_nested_types( timeout=BROADCAST_TIMEOUT, ) - # a second entry via the strip, then Remove via the row button + # a second entry via the strip, then Remove via the row button; + # the unit is typed with a trailing space, which the strip strips + # (D12 compares units exactly, so "Hz " would match no Instance) gui.typesPane.entryNameEdit.setText("bw") gui.typesPane.entryDefaultEdit.setText("2.0") - gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.entryUnitEdit.setText("Hz ") gui.typesPane.addEntryButton.click() qtbot.waitUntil( lambda: gui.typesPane.entryWidgets.get("bw", {}).get("remove") @@ -2516,6 +2590,7 @@ def test_the_types_tab_edits_entries_and_nested_types( timeout=BROADCAST_TIMEOUT, ) assert pm.get_type("qubit").parameters["bw"]["default"] == 2.0 + assert pm.get_type("qubit").parameters["bw"]["unit"] == "Hz" gui.typesPane.entryWidgets["bw"]["remove"].click() qtbot.waitUntil( lambda: "bw" not in pm.get_type("qubit").parameters, @@ -2657,6 +2732,19 @@ def _state_target(): assert gui.armed_type_lock == ("qubit", "IF") assert gui.armed_follower is None + # a Target the Server refuses: the error text on the strip, which + # stays armed, and the entry's Target unchanged on the Server + gui.pick_lock_target("no.such.path") + qtbot.waitUntil( + lambda: "no.such.path" in gui.armStrip.errorLabel.text(), + timeout=BROADCAST_TIMEOUT, + ) + assert not gui.armStrip.errorLabel.isHidden() + assert not gui.armStrip.isHidden() + assert gui.armed_type_lock == ("qubit", "IF") + assert gui.armed_follower is None + assert pm.get_type("qubit").parameters["IF"]["target"] == globals_target + gui.pick_lock_target("tshared") qtbot.waitUntil( lambda: pm.get_type("qubit").parameters["IF"]["target"] @@ -2786,5 +2874,24 @@ def test_the_types_tab_shows_server_errors_and_empty_names( gui.typesPane.entryNameEdit.setText("") gui.typesPane.addEntryButton.click() assert gui.typesPane.entriesNote.text() == "Name must not be empty." + + # the "Nested type" strip refuses an empty submodule before any + # Server call: a second Type is created so the combo has the + # other Type to offer + gui.typesPane.newTypeEdit.setText("errtype2") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "errtype2", + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: gui.typesPane.nestedTypeCombo.currentText() == "errtype", + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.nestedAtEdit.setText("") + gui.typesPane.addNestedButton.click() + assert gui.typesPane.entriesNote.text() == "Submodule must not be empty." + # the Server's Nested Types are untouched + assert pm.get_type("errtype2").nested == {} finally: gui.model.stopListener() From 6d12b7312d03815dbab65358c02614857838dc98 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 15:43:18 -0500 Subject: [PATCH 082/107] 5.5: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 52 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 7670e3c..a1509fa 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -887,3 +887,55 @@ The Parameters tab now holds `gui.view` and a new `LocksPanel` side by side in a ### Process notes - The coder sat idle for about 15 minutes after its read pass, and one nudge got it going, the same pattern as in 2.3 and 5.3. - Two permission requests were rejected. The coder's `rm -rf orchestration/5.4` would have deleted the orchestrator's files (the 2.4 coder tried the same), so it deleted its own logs by name instead. plan-checker-qwen asked to access opencode's temp directory under `/var/folders`, outside the repo. + +## 5.5 Types tab — 2026-09-28 + +The "Types" tab (`self.typesTab`) now holds a `TypesPane` (`self.typesPane`). It is a horizontal `QSplitter` with three panes: the type list (`typeList`: name, number of Instances, number of effective parameters, tinted from `gui.typePalette`); the entries of the selected Type as a tree (`entriesView`); and its Instances (`instancesView`). Each pane has its own strip and note line. The pane only emits camelCase signals, and `ParameterManagerGui` slots (`_on_pane_*`, `arm_type_lock`) make the Server calls. The rows come from new pure functions: `type_entry_rows` (returning `EntryRow`s), `instances_of_type`, `also_types` and `parse_default_text`. The task also fixed the pre-existing crash in `ModelParameters.updateParameter`'s `parameter-creation` branch, which Marcos approved as an exception to plan rule 6 (see Questions to Marcos). `test_pm_gui.py` grew from 53 to 71 tests. + +### Commit by commit +- `7c92542` The creation-branch fix, `TypesPane`, the pure functions, the Type Lock re-target and 17 tests. The orchestrator's coder spec set twelve readings. The main ones: + - Reading 0, the creation fix. The branch now resolves the element first. On `AttributeError` it calls `self.instrument.update()` (only if the instrument has `update`, that is, a Proxy Instrument) and resolves again. If that also fails, it logs at debug level and adds no row. Before the fix, `update()` ran only when the path was missing from `self.instrument.list()`. That is a remote call, and it already contained the new parameter, so the stale Proxy was never refreshed. The `TEST_AUDIT.md` row "Parameter Manager GUI — live creation from another client" became `fixed`. The module docstring no longer describes the creation trap. reviewer-glm and reviewer-qwen checked the fix against `client/proxy.py` and `helpers.py`. + - The pane widgets are named attributes, and every string comes from the mock with "source" changed to "target" and "include" changed to "nested type". Own entries get an editable default, whose text goes through `ast.literal_eval` and falls back to the raw string, plus Remove and a Type Lock toggle. The toggle calls `lock_type_parameter` with the Globals default, or `unlock_type_parameter`. While an entry is locked it also shows a re-target button and the relative Target. Nested entries are read-only and show "defined by ". Nested submodule rows show `type: `, and a Remove button for the selected Type's own Nested Types. + - The re-target goes through the arm strip. `arm_type_lock` sets `armed_type_lock`, switches to the Parameters tab and arms the strip with "Target for type · ". The candidates are ranked by `rank_lock_targets` with a new optional `arm_rel`. On a pick, `pick_lock_target` calls `lock_type_parameter(type, path, target=picked)`. `arm_lock` and `cancel_arm` clear both kinds of arm. Skipped Locks from either the toggle or the re-target show as `skipped: …` on `entriesNote`. + - `refresh_types_pane` runs at the end of `apply_tints`. So `refreshAll`, `loadProfile`, `typeChanged` and `structureChanged` all rebuild the panes. The panes also rebuild after every pane action, and the selected Type is kept across rebuilds. + + The coder's own choices: + - It extracted `_instance_candidates` from `compute_claims` so the new matching could share it; the behaviour of `compute_claims` is unchanged. + - `type_entry_rows` has a third argument, `instrument_name=""`, used to make stored Targets relative. + - `requestedType` carries a just-added Type across the gap before its `pm-type-update` Broadcast arrives. + - A `_building` guard keeps the selection slot quiet during a rebuild. + - The entries pane calls `deleteLater` on its index widgets before `removeRows`. The instances pane does not (see Dropped findings). + + The tests: + - Seven no-server tests of `type_entry_rows` (the segment-sorted tree, deeper Nested Types, an unknown Type), `instances_of_type`, `also_types`, `parse_default_text` and `rank_lock_targets` with a Type Lock arm. + - Four live regression tests for reading 0: a second Client's parameter under an existing submodule and in a new submodule, a second Client's `add_instance` (rows, widgets and tint), and the GUI's own Proxy calling `add_instance`. + - Six live Types-tab tests. `test_the_types_tab_creates_a_type_and_an_instance` is the plan's named test. It creates Type `qubit`, entry `IF` and Instance `q10` through the widgets, checks `get_type` and `pm.q10.IF` on the Server and the tint on the `q10.IF` tree row, and then shows that a second Client's entry `bw` appears in the pane and as `q10.bw`. The others are `…_edits_entries_and_nested_types`, `…_type_locks_toggle_and_retarget` (the Globals-default Type Lock, now testable live), `…_names_skipped_locks_on_the_note`, `…_show_button_and_also_types` and `…_shows_server_errors_and_empty_names`. + + Orchestrator run: ruff clean, 80 in the three named GUI files, 536 in the full suite. +- `edceebc` Fix from round 0, five items: + - The named test now inlines the widget steps and asserts the type list's counts: parameters `1`, then `2` after the second Client's `bw`; Instances `0`, then `1` after `q10`. Nothing had asserted those columns before. Both test reviewers raised it (should-fix). + - The Type Lock re-target failure path: `pick_lock_target("no.such.path")` shows the Server's text on the strip, which stays armed with `armed_type_lock == ("qubit", "IF")`, and the entry's Target is unchanged. test-reviewer-glm raised it as should-fix and test-reviewer-qwen as a nit. + - The nested strip's "Submodule must not be empty." guard, which had no test (test-reviewer-qwen, should-fix). + - New `test_instances_of_type_with_a_nested_type`: the Instance matches only when the nested entry is present with the right unit. There is also a truly empty Type in the registry. Before, the empty-Type check passed an unknown name and so tested a different branch. test-reviewer-qwen raised it as should-fix and test-reviewer-glm as a nit. + - `TypesPane._request_add_entry` now strips the unit text. D12 compares units exactly, so a trailing space would silently leave the Type with no Instances. The edit test types `"Hz "` and checks that the Server stores `"Hz"`. reviewer-qwen called it a nit. The orchestrator kept it because it is a silent matching failure and a one-line fix. + + All six approved in re-review with no new findings. Orchestrator run: ruff clean, 81 in the three named GUI files, 537 in the full suite. + +### Dropped findings +- reviewer-qwen said (should-fix) that the instances pane's Show buttons pile up, because `_rebuild_instances` removes rows without deleting their index widgets. reviewer-glm's probe said Qt deletes them. The orchestrator ran its own probe: once deferred deletes are flushed, no widgets pile up. reviewer-qwen's probe had only called `processEvents`. Not sent. In round 1, reviewer-qwen agreed after a probe with a real event loop. Its matching note about the 5.4 `LocksPanel` fell with it. The entries pane's `deleteLater` is therefore redundant but harmless. +- Not sent: the creation branch's "cannot resolve → no row" guard is untested, and several one-line cell checks are missing: the unit column, the Target label, the Return commit, and `.locked` after toggle-off (test-reviewer-glm N2, N3). +- Also not sent: the nested-Type combo offers Types that would form a cycle, and the Server's refusal shows instead (plan-checker-qwen). The word "source" in `arm_type_lock`'s docstring means Qt's source model (plan-checker-qwen). Width comments quote the mock's numbers (plan-checker-glm). + +### Questions to Marcos +- The named test needs parameters created while the GUI is open, which hits the 5.1 crash logged in `TEST_AUDIT.md`. The orchestrator's probe showed that every creation shape failed and that a forced `update()` fixed them all. Should 5.5 fix it as an exception to plan rule 6? → "fix it in 5.5". + +### Loose ends +- The entries note is not cleared after a clean re-target, so a stale error or `skipped:` line can stay (reviewer-glm, reviewer-qwen). With no Type selected, the strips silently do nothing, and the labels read "parameters of " (reviewer-glm). All three are logged in `decisions.md` for 5.6 polish, next to 5.4's stale-note loose end. +- `test_a_deletion_broadcast_recomputes_the_tints` still has a stale docstring saying creation "stays off-limits" (test-reviewer-qwen). +- 5.2's loose end about stale units after `set_type_parameter_unit` is still open. The Types tab has no unit editor, so the GUI cannot reach it yet. +- A stray untracked profile, `parameter_manager-parameter_manager.json`, sat in the repo root: parameters `hello.salud`/`salud`, unit `q`, a Lock, apparently from a manual GUI run. Every `ParameterManager` built in the repo root loads it, which made `test_pm_locks.py` fail (2 failures). The coder asked whether to delete it. The orchestrator said no, because it is Marcos's data, and kept it moved aside at `orchestration/5.5/stray-parameter_manager-parameter_manager.json` (git-ignored). It is flagged for Marcos: a profile file in the repo root breaks the suite. + +### Process notes +- The coder sat idle after its read pass, and one nudge got it going, the same pattern as in 5.3 and 5.4. +- The coder moved the stray profile out of the repo root first and asked about it afterwards. The orchestrator had allowed the move because it could be undone. +- Three permission requests were rejected: test-reviewer-qwen (twice) and plan-checker-glm asked to access opencode's temp directory under `/var/folders`, outside the repo. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 1c9325c..1f32c33 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -588,7 +588,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session (`unlock_type_parameter`). "Lock selection to…" button arms for the tree's current row. Tests: `test_pm_gui.py` — panel rows reflect `list_locks`; remove from panel removes on server; live update when a second client locks. -- [ ] **5.5 Types tab.** Three panes per the Design reference: type list (name, #instances, +- [x] **5.5 Types tab.** Three panes per the Design reference: type list (name, #instances, #params; New type strip → `add_type`); entries of the selected Type as a tree (own entries editable default → `set_type_parameter_default`, Remove → `remove_type_parameter`, Type Lock toggle → `lock_type_parameter`/`unlock_type_parameter`, re-target via arm; entries From f4923e39bd01b22715d239c234d636100c4d0100 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 16:30:30 -0500 Subject: [PATCH 083/107] 5.6: delete-Target confirmation, Lock shortcuts and Types-tab polish --- src/instrumentserver/gui/instruments.py | 158 ++++++++++-- src/instrumentserver/gui/shortcuts.py | 6 + test/pytest/test_pm_gui.py | 324 +++++++++++++++++++++++- 3 files changed, 468 insertions(+), 20 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 30fe086..c403f7e 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -617,11 +617,36 @@ def insertItemTo( self.newItem.emit(item) + def _has_row(self, full_name: str) -> bool: + """Whether the model holds a row for the dotted path ``full_name`` + (the Broadcast name with the instrument name stripped).""" + return bool( + self.findItems( + full_name, + cast( + "QtCore.Qt.MatchFlags", + QtCore.Qt.MatchFlag.MatchExactly + | QtCore.Qt.MatchFlag.MatchRecursive, + ), + 0, + ) + ) + def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: + fullName = ".".join(bp.name.split(".")[1:]) + known_row = bp.action == PARAMETER_UPDATE and self._has_row(fullName) super().updateParameter(bp) - if bp.action in (PARAMETER_CREATION, PARAMETER_DELETION): - # matching depends on which parameters exist: the tints and - # gutter bands must be recomputed after a structural Broadcast + # a parameter-update for a row the model did not know adds one + # through the base update branch; matching depends on which + # parameters exist, so the tints and gutter bands must be + # recomputed for it too (plan task 5.6), or the new row would + # stay untinted until the next recompute + added_row = ( + bp.action == PARAMETER_UPDATE + and not known_row + and self._has_row(fullName) + ) + if bp.action in (PARAMETER_CREATION, PARAMETER_DELETION) or added_row: self.structureChanged.emit() @@ -1388,10 +1413,11 @@ class LockArmStrip(QtWidgets.QWidget): error label for the Server's refusal text. Picking works three ways: a completion from the popup, Return with the - exact typed path (or the first ranked candidate when the text is not a - path), and clicking a tree row — the last one is wired by the - Parameter Manager GUI, which owns the strip. Cancel is the button or - Escape while the strip or one of its children has focus.""" + exact typed path (or the first completion the completer filters for + the typed text; a text that matches no candidate picks nothing), and + clicking a tree row — the last one is wired by the Parameter Manager + GUI, which owns the strip. Cancel is the button or Escape while the + strip or one of its children has focus.""" #: Signal(str) #: Emitted when a Target was picked. The path is relative to the @@ -1533,9 +1559,10 @@ class LocksPanel(QtWidgets.QWidget): A Target row holds a value editor (a plain ``set`` on the Target); a locked Follower row shows its value read-only, and every Follower row - carries the lock/relock toggle and the remove button. A Type Lock row - carries "lock all" and "remove rule". The panel never talks to the - Server itself: every action is emitted as a signal — + that is not a Type Lock row carries the lock/relock toggle and the + remove button. A Type Lock row carries "lock all" and "remove rule". + The panel never talks to the Server itself: every action is emitted as + a signal — ``toggleLockRequested``, ``removeLockRequested``, ``lockAllRequested``, ``removeRuleRequested`` and ``lockSelectionRequested`` — and the Parameter Manager GUI, which owns the panel, performs it and reports @@ -2400,8 +2427,19 @@ def _rebuild_selected_panes( palette: TypePalette, ) -> None: selected = self.selectedType or "" - self.entriesLabel.setText(f"parameters of {selected}") - self.instancesLabel.setText(f"instances of {selected}") + # with no Type selected the labels keep no trailing space and the + # three strips are disabled — their actions all need a Type + # (plan task 5.6) + has_type = bool(selected) + self.entriesLabel.setText( + f"parameters of {selected}" if has_type else "parameters" + ) + self.instancesLabel.setText( + f"instances of {selected}" if has_type else "instances" + ) + self.addEntryButton.setEnabled(has_type) + self.addNestedButton.setEnabled(has_type) + self.addInstanceButton.setEnabled(has_type) self._rebuild_nested_combo(selected, types) self._rebuild_entries(selected, types, palette) self._rebuild_instances(selected, types, parameters, palette) @@ -3192,6 +3230,10 @@ def __init__( ) self.viewEscShortcut.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) self.viewEscShortcut.activated.connect(self.cancel_arm) + # The confirmation dialog for removing a Lock Target (plan task + # 5.6), kept on the GUI so tests can drive it; ``None`` while no + # removal that needs one is in flight. + self.removalDialog: Optional[QtWidgets.QMessageBox] = None self.connectSignals() self.loadProfile() @@ -3254,6 +3296,13 @@ def connectSignals(self) -> None: self.shortcutManager.register("load_items", self.loadFromFile, self) self.shortcutManager.register("save_items", self.saveToFile, self) self.shortcutManager.register("toggle_locks", self.locksAction.toggle, self) + # the Lock shortcuts (plan task 5.6); the tree's two lock actions + # carry their key in their tooltips + self.shortcutManager.register("lock_to", self._lock_current_item, self) + self.shortcutManager.register("unlock_item", self._unlock_current_item, self) + self.shortcutManager.register("show_types", self._toggle_tabs, self) + self.shortcutManager.register_tooltip("lock_to", self.view.lockToAction) + self.shortcutManager.register_tooltip("unlock_item", self.view.unlockAction) @QtCore.Slot() def _deleteCurrentItem(self) -> None: @@ -3301,8 +3350,54 @@ def refreshAll(self) -> None: self.apply_locks() def removeParameter(self, fullName: str) -> None: - if self.instrument.has_param(fullName): - self.instrument.remove_parameter(fullName) + """Remove the parameter at ``fullName`` (the row's delete button + and the delete_item shortcut both land here). + + While the parameter is the Target of Locks — deleting it drops + them (D3) — a QMessageBox names every Follower that will lose its + Lock and asks for confirmation (plan task 5.6); Cancel returns + without touching the Server. A parameter without Followers is + removed without a dialog.""" + self.removalDialog = None + if not self.instrument.has_param(fullName): + return + try: + followers = self.instrument.followers_of(fullName) + except Exception: + # the Server call failed: fall back to the client-side list + # computed from the state — locked and unlocked alike, the + # Targets compared through relative_path + followers = sorted( + follower + for follower, lock in self.state.locks.items() + if relative_path(lock.target, self.instrument.name) == fullName + ) + if followers: + lines = [] + for follower in followers: + lock = self.state.locks.get(follower) + state = "locked" if lock is not None and lock.locked else "unlocked" + lines.append(f"{follower} ({state})") + box = QtWidgets.QMessageBox(self) + box.setObjectName("removalDialog") + box.setIcon(QtWidgets.QMessageBox.Icon.Question) + box.setWindowTitle("Remove Target?") + # macOS ignores a QMessageBox's window title (windowTitle() + # reads back empty there); tests pin the dialog through its + # object name and text instead. + box.setText( + f"Removing {fullName} also removes the Locks of:\n" + + "\n".join(lines) + ) + box.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel + ) + box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + self.removalDialog = box + if box.exec() != QtWidgets.QMessageBox.StandardButton.Ok: + return + self.instrument.remove_parameter(fullName) def addParameter(self, fullName: str, value: Any, unit: str) -> None: try: @@ -3487,6 +3582,33 @@ def _unlock(self, path: str) -> None: if widget is not None: widget.alertWidget.setAlert(str(e)) + def _lock_current_item(self) -> None: + """The lock_to shortcut (plan task 5.6): arm the target picker for + the tree's current parameter row. A submodule row or no selection + does nothing.""" + item = self._getCurrentItem() + if item is not None and item.element is not None: + self.arm_lock(item.name) + + def _unlock_current_item(self) -> None: + """The unlock_item shortcut (plan task 5.6): unlock the Lock of + the tree's current parameter row while it is locked; a row + without a locked Lock does nothing. A refused unlock shows the + Server's error text on the row's alert widget, like the context + menu's Unlock.""" + item = self._getCurrentItem() + if item is None or item.element is None: + return + lock = self.state.locks.get(item.name) + if lock is None or not lock.locked: + return + self._unlock(item.name) + + def _toggle_tabs(self) -> None: + """The show_types shortcut (plan task 5.6): switch between the + Parameters and Types tabs.""" + self.tabs.setCurrentIndex(1 if self.tabs.currentIndex() == 0 else 0) + @QtCore.Slot() def _update_lock_actions(self) -> None: """Enable the context menu's lock actions for the row the menu was @@ -3553,6 +3675,10 @@ def pick_lock_target(self, target: str) -> None: self.typesPane.show_entries_note( f"skipped: {', '.join(skipped)}" ) + else: + # a clean declaration leaves no stale error or + # skipped note behind (plan task 5.6) + self.typesPane.reset_entries_note() self.cancel_arm() return if self.armed_follower is None: @@ -3685,11 +3811,13 @@ def _on_panel_remove_rule(self, type_name: str, entry: str) -> None: def _lock_selection_from_panel(self) -> None: """The panel's "Lock selection to…": arm the target picker for the tree's current parameter row. With no parameter row current, the - note label says so and nothing is armed.""" + note label says so and nothing is armed; a successful arm clears a + stale error from the note (plan task 5.6).""" item = self._getCurrentItem() if item is None or item.element is None: self.locksPanel.show_error("Select a parameter in the tree first.") return + self.locksPanel.reset_note() self.arm_lock(item.name) @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) diff --git a/src/instrumentserver/gui/shortcuts.py b/src/instrumentserver/gui/shortcuts.py index d635331..5ddb583 100644 --- a/src/instrumentserver/gui/shortcuts.py +++ b/src/instrumentserver/gui/shortcuts.py @@ -44,6 +44,12 @@ class KeyboardShortcutManager: "fit_column": ("Ctrl+Shift+D", "Fits column width"), "sort_column": ("Ctrl+D", "Toggle sorting of selected column"), "toggle_locks": ("Ctrl+Shift+L", "Show or hide the Locks panel"), + "lock_to": ("Ctrl+L", "Lock the selected parameter to… (pick a Target)"), + "unlock_item": ("Ctrl+U", "Unlock the selected parameter"), + "show_types": ( + "Ctrl+Shift+Y", + "Switch between the Parameters and Types tabs", + ), } def __init__(self) -> None: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index ab19276..ba0667b 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -31,7 +31,15 @@ With the creation branch fixed, parameters may be created while the GUI is open, so the 5.5 tests drive everything through the widgets — and the Globals-default Type Lock, whose declaration creates the -``_globals..`` parameter, is testable live too. +``_globals..`` parameter, is testable live too. The 5.6 tests +cover the delete-Target confirmation (Cancel leaves the Server untouched, +Ok removes the Target and drops the Locks, a parameter without Followers +goes without a dialog), the three Lock shortcut REGISTRY entries and +their keys (Ctrl+L arm, Ctrl+U unlock, Ctrl+Shift+Y tab switch), the +lock/unlock icons in the compiled resources, and the 5.6 polish: a +parameter-update for an unknown row recomputes the tints, stale notes are +reset on success, and with no Type selected the Types tab strips are +disabled and the pane labels carry no trailing space. """ import os @@ -40,7 +48,12 @@ from qcodes.instrument import InstrumentBase from instrumentserver import QtCore, QtWidgets -from instrumentserver.blueprints import PMLockBluePrint, PMTypeBluePrint +from instrumentserver.blueprints import ( + PARAMETER_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, +) from instrumentserver.client.proxy import Client from instrumentserver.gui.base_instrument import InstrumentSortFilterProxyModel from instrumentserver.gui.instruments import ( @@ -882,7 +895,8 @@ def test_a_deletion_broadcast_recomputes_the_tints( and recomputes the tints: the submodule that stops carrying the whole set loses its Claim, so the surviving rows show no tint and no gutter band. Deletion is safe live (the model's deletion branch touches no - Proxy blueprint); creation stays off-limits (TEST_AUDIT trap).""" + Proxy blueprint), and creation is too since plan task 5.5 fixed the + creation branch.""" second_pm = _second_parameter_manager(second_client) pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") pm.add_parameter("q01.bw", initial_value=2.0, unit="Hz") @@ -1043,8 +1057,8 @@ def test_rank_lock_targets_without_a_claim_is_alphabetical(): def test_lock_arm_strip_picks_cancels_and_shows_errors(qtbot): """The arm strip picks with Return (the exact path, or the first - ranked candidate when the text is not a path), cancels with Escape and - the Cancel button, shows the error text, and disarms.""" + completion the completer filters for the typed text), cancels with + Escape and the Cancel button, shows the error text, and disarms.""" strip = LockArmStrip() qtbot.addWidget(strip) picked = [] @@ -1993,6 +2007,14 @@ def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_po ) assert gui.armed_follower is None assert gui.armStrip.isHidden() + + # a successful arm clears the stale error from the note + # (plan task 5.6) + source_index = gui.model.indexFromItem(_row_items(gui, "other.x")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + gui.locksPanel.lockSelectionButton.click() + assert gui.armed_follower == "other.x" + assert gui.locksPanel.noteLabel.text() == LOCK_PANEL_NOTE finally: gui.model.stopListener() @@ -2462,6 +2484,14 @@ def test_the_types_tab_creates_a_type_and_an_instance( try: _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + # no Type selected yet: the labels carry no trailing space and the + # three strips are disabled (plan task 5.6) + assert gui.typesPane.entriesLabel.text() == "parameters" + assert gui.typesPane.instancesLabel.text() == "instances" + assert not gui.typesPane.addEntryButton.isEnabled() + assert not gui.typesPane.addNestedButton.isEnabled() + assert not gui.typesPane.addInstanceButton.isEnabled() + # create the Type through the widgets gui.tabs.setCurrentIndex(1) gui.typesPane.newTypeEdit.setText("qubit") @@ -2474,6 +2504,13 @@ def test_the_types_tab_creates_a_type_and_an_instance( and _type_list_row(gui, "qubit") is not None, timeout=BROADCAST_TIMEOUT, ) + # with the Type selected the strips are enabled and the labels + # name it + assert gui.typesPane.addEntryButton.isEnabled() + assert gui.typesPane.addNestedButton.isEnabled() + assert gui.typesPane.addInstanceButton.isEnabled() + assert gui.typesPane.entriesLabel.text() == "parameters of qubit" + assert gui.typesPane.instancesLabel.text() == "instances of qubit" # add the entry through the widgets gui.typesPane.entryNameEdit.setText("IF") @@ -2791,6 +2828,20 @@ def test_the_types_tab_names_skipped_locks_on_the_note( timeout=BROADCAST_TIMEOUT, ) assert pm.get_lock("q10.IF").target == f"{PM_NAME}.tshared" + + # a clean Type Lock re-target with nothing skipped resets the + # note (plan task 5.6); re-targeting to the Follower's own Target + # skips nothing + gui.arm_type_lock("qubit", "IF") + assert gui.armed_type_lock == ("qubit", "IF") + gui.pick_lock_target("tshared") + qtbot.waitUntil( + lambda: pm.get_type("qubit").parameters["IF"]["target"] + == f"{PM_NAME}.tshared", + timeout=BROADCAST_TIMEOUT, + ) + assert gui.typesPane.entriesNote.text() == "" + assert gui.armed_type_lock is None finally: gui.model.stopListener() @@ -2895,3 +2946,266 @@ def test_the_types_tab_shows_server_errors_and_empty_names( assert pm.get_type("errtype2").nested == {} finally: gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.6: delete-Target confirmation, shortcuts, icons, polish +# --------------------------------------------------------------------------- + + +def test_lock_and_unlock_icons_ship_in_the_resources(): + """resource.qrc lists lock.svg and unlock.svg (plan task 5.3 copied + them into resource/icons), so the compiled resources expose both.""" + import instrumentserver.resource # noqa: F401 + + assert QtCore.QFile.exists(":/icons/lock.svg") + assert QtCore.QFile.exists(":/icons/unlock.svg") + + +def test_the_lock_shortcuts_are_in_the_registry(): + """The three shortcut REGISTRY entries (plan task 5.6) sit after + ``toggle_locks`` — the first key stays ``jump_filter`` — and no key + collides with another entry.""" + registry = KeyboardShortcutManager.REGISTRY + assert registry["lock_to"] == ( + "Ctrl+L", + "Lock the selected parameter to… (pick a Target)", + ) + assert registry["unlock_item"] == ("Ctrl+U", "Unlock the selected parameter") + assert registry["show_types"] == ( + "Ctrl+Shift+Y", + "Switch between the Parameters and Types tabs", + ) + assert list(registry)[0] == "jump_filter" + keys = [entry[0] for entry in registry.values()] + assert len(keys) == len(set(keys)) + + +def _row_remove_button(gui, path): + """The row's delete button (the delegate's additional widget with the + ``Delete this parameter`` tooltip).""" + widget = gui.view.delegate.parameters[path] + buttons = [ + button + for button in widget.findChildren(QtWidgets.QPushButton) + if button.toolTip() == "Delete this parameter" + ] + assert len(buttons) == 1, f"expected one delete button on {path}" + return buttons[0] + + +def test_removing_a_target_confirms_and_cancel_keeps_the_server_untouched( + qtbot, pm, second_client, server_port +): + """The plan's named test: deleting a Target — through the row's delete + button or the delete_item shortcut — pops a QMessageBox naming the + Followers that will lose their Locks; Cancel leaves the Server + untouched and no pm-lock-update is emitted, Ok removes the Target and + drops the Locks, and a parameter without Followers is removed without + a dialog.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.removalDialog is None + + def _cancel_dialog(): + dialog = gui.removalDialog + assert dialog is not None + # macOS ignores a QMessageBox's window title (it reads back + # empty there), so the object name and text pin the dialog + assert dialog.objectName() == "removalDialog" + assert dialog.text().startswith( + "Removing q02.IF also removes the Locks of:" + ) + assert "q01.IF (locked)" in dialog.text() + assert ( + dialog.standardButtons() + & QtWidgets.QMessageBox.StandardButton.Ok + ) + assert ( + dialog.standardButtons() + & QtWidgets.QMessageBox.StandardButton.Cancel + ) + dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel).click() + + # the row's delete button path: the dialog appears, Cancel keeps + # the Target and its Followers' Locks untouched + QtCore.QTimer.singleShot(0, _cancel_dialog) + _row_remove_button(gui, "q02.IF").click() + qtbot.wait(300) # a pm-lock-update would have arrived by now + assert pm.has_param("q02.IF") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + assert gui.state.locks.get("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + + # the delete_item shortcut path, with the row current + source_index = gui.model.indexFromItem(_row_items(gui, "q02.IF")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + QtCore.QTimer.singleShot(0, _cancel_dialog) + gui._deleteCurrentItem() + qtbot.wait(300) + assert pm.has_param("q02.IF") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + + # the Ok path: the Target is gone, its Follower's Lock with it + def _accept_dialog(): + dialog = gui.removalDialog + assert dialog is not None + dialog.button(QtWidgets.QMessageBox.StandardButton.Ok).click() + + QtCore.QTimer.singleShot(0, _accept_dialog) + _row_remove_button(gui, "q02.IF").click() + qtbot.waitUntil( + lambda: not pm.has_param("q02.IF"), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF") is None, timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "", + timeout=BROADCAST_TIMEOUT, + ) + assert "q01.IF" not in gui.state.locks + + # a parameter without Followers is removed with no dialog + gui.removeParameter("other.x") + qtbot.waitUntil( + lambda: not pm.has_param("other.x"), timeout=BROADCAST_TIMEOUT + ) + assert gui.removalDialog is None + finally: + gui.model.stopListener() + + +def test_the_lock_shortcuts_arm_unlock_and_switch_tabs( + qtbot, pm, second_client, server_port +): + """Ctrl+L arms the pick for the tree's current parameter row (and + does nothing on a submodule row), Ctrl+U unlocks the current locked + Follower, and Ctrl+Shift+Y switches between the tabs.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + + gui.show() + qtbot.waitExposed(gui) + gui.view.expandAll() + control = QtCore.Qt.KeyboardModifier.ControlModifier + control_shift = ( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier + ) + + # Ctrl+L with the q01.IF row current arms the pick for it + source_index = gui.model.indexFromItem(_row_items(gui, "q01.IF")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + gui.view.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_L, control) + assert gui.armed_follower == "q01.IF" + assert not gui.armStrip.isHidden() + gui.cancel_arm() + + # Ctrl+U on the locked Follower unlocks it on the Server. Hiding + # the armed strip hands focus to the next row editor, and the + # navigation filter's FocusIn moves the tree's current row there — + # re-establish the row the shortcut should act on. + source_index = gui.model.indexFromItem(_row_items(gui, "q01.IF")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + gui.view.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_U, control) + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + + # Ctrl+U on an unlocked Follower does nothing: the Lock stays + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_U, control) + qtbot.wait(300) + assert pm.get_lock("q01.IF") is not None + + # Ctrl+L on a submodule row arms nothing + source_index = gui.model.indexFromItem(_row_items(gui, "q01")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_L, control) + assert gui.armed_follower is None + assert gui.armStrip.isHidden() + + # Ctrl+Shift+Y toggles between the tabs + assert gui.tabs.currentIndex() == 0 + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_Y, control_shift) + assert gui.tabs.currentIndex() == 1 + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_Y, control_shift) + assert gui.tabs.currentIndex() == 0 + finally: + gui.model.stopListener() + + +def test_a_parameter_update_for_an_unknown_row_recomputes_the_tints( + qtbot, pm, second_client, server_port +): + """A parameter-update Broadcast for a parameter the model does not + know adds the row through the base update branch; the tints are + recomputed for it too (plan task 5.6), so the new row carries the + claiming Type's tint right away instead of staying untinted until the + next recompute.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + + # created while the listener is stopped: the creation Broadcast is + # lost, so the model has no q02.IF row; refreshing the GUI's own + # Proxy (without reloading the model) makes the parameter resolve + # when the update Broadcast arrives + second_pm.add_parameter("q02.IF", initial_value=2.0, unit="Hz") + pm.update() + assert not _row_exists(gui, "q02.IF") + + gui.model.updateParameter( + ParameterBroadcastBluePrint( + name=f"{PM_NAME}.q02.IF", + action=PARAMETER_UPDATE, + value=2.0, + unit="Hz", + ) + ) + qtbot.waitUntil( + lambda: _row_exists(gui, "q02.IF"), timeout=BROADCAST_TIMEOUT + ) + tint = _type_tint(gui, "qubit") + assert tint is not None + for item in _row_items(gui, "q02.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + assert _row_items(gui, "q02.IF")[3].data(GUTTER_ROLE) == ["qubit"] + finally: + gui.model.stopListener() From e32078a66480000e4ae8282067a1256bc8875a90 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 16:57:27 -0500 Subject: [PATCH 084/107] 5.6: fix from review round 1: followers_of fallback test, unlocked-Follower dialog line and default button, removalDialog reset, parameter-call recompute --- src/instrumentserver/gui/instruments.py | 29 ++++--- test/pytest/test_pm_gui.py | 109 ++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index c403f7e..23ffafd 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -567,9 +567,10 @@ def insertItemTo( class ModelParameterManager(ModelParameters): #: Signal() -- - #: Emitted after a Broadcast changed the tree's structure (a parameter - #: was created or removed), so the Parameter Manager GUI can recompute - #: the Type claims that the tints and gutter bands show. + #: Emitted after a Broadcast changed the tree's structure: a parameter + #: was created or removed, or a ``parameter-update``/``parameter-call`` + #: added a row the model did not know. The Parameter Manager GUI + #: recomputes the Type claims that the tints and gutter bands show. structureChanged = QtCore.Signal() def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -634,18 +635,15 @@ def _has_row(self, full_name: str) -> bool: def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: fullName = ".".join(bp.name.split(".")[1:]) - known_row = bp.action == PARAMETER_UPDATE and self._has_row(fullName) + value_update = bp.action in (PARAMETER_UPDATE, PARAMETER_CALL) + known_row = value_update and self._has_row(fullName) super().updateParameter(bp) - # a parameter-update for a row the model did not know adds one - # through the base update branch; matching depends on which - # parameters exist, so the tints and gutter bands must be + # a parameter-update or parameter-call for a row the model did not + # know adds one through the base update branch; matching depends + # on which parameters exist, so the tints and gutter bands must be # recomputed for it too (plan task 5.6), or the new row would # stay untinted until the next recompute - added_row = ( - bp.action == PARAMETER_UPDATE - and not known_row - and self._has_row(fullName) - ) + added_row = value_update and not known_row and self._has_row(fullName) if bp.action in (PARAMETER_CREATION, PARAMETER_DELETION) or added_row: self.structureChanged.emit() @@ -3395,7 +3393,12 @@ def removeParameter(self, fullName: str) -> None: ) box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) self.removalDialog = box - if box.exec() != QtWidgets.QMessageBox.StandardButton.Ok: + clicked = box.exec() + # the box is closed on both paths: the attribute matches its + # docstring again (the tests' QTimer callbacks read it while + # the box is open, so they keep working) + self.removalDialog = None + if clicked != QtWidgets.QMessageBox.StandardButton.Ok: return self.instrument.remove_parameter(fullName) diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index ba0667b..1f62f70 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -49,6 +49,7 @@ from instrumentserver import QtCore, QtWidgets from instrumentserver.blueprints import ( + PARAMETER_CALL, PARAMETER_UPDATE, ParameterBroadcastBluePrint, PMLockBluePrint, @@ -2999,10 +3000,11 @@ def test_removing_a_target_confirms_and_cancel_keeps_the_server_untouched( ): """The plan's named test: deleting a Target — through the row's delete button or the delete_item shortcut — pops a QMessageBox naming the - Followers that will lose their Locks; Cancel leaves the Server - untouched and no pm-lock-update is emitted, Ok removes the Target and - drops the Locks, and a parameter without Followers is removed without - a dialog.""" + Followers that will lose their Locks (locked and unlocked alike, with + Cancel as the default button); Cancel leaves the Server untouched and + no pm-lock-update is emitted, Ok removes the Target and drops the + Locks, and a parameter without Followers is removed without a + dialog.""" second_pm = _second_parameter_manager(second_client) _make_live_parameters(pm) @@ -3015,6 +3017,15 @@ def test_removing_a_target_confirms_and_cancel_keeps_the_server_untouched( == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), timeout=BROADCAST_TIMEOUT, ) + # a second Follower whose Lock the second Client unlocked: the + # dialog names both states + second_pm.lock("q03.IF", "q02.IF") + second_pm.unlock("q03.IF") + qtbot.waitUntil( + lambda: gui.state.locks.get("q03.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=False), + timeout=BROADCAST_TIMEOUT, + ) assert gui.removalDialog is None def _cancel_dialog(): @@ -3027,6 +3038,7 @@ def _cancel_dialog(): "Removing q02.IF also removes the Locks of:" ) assert "q01.IF (locked)" in dialog.text() + assert "q03.IF (unlocked)" in dialog.text() assert ( dialog.standardButtons() & QtWidgets.QMessageBox.StandardButton.Ok @@ -3035,6 +3047,10 @@ def _cancel_dialog(): dialog.standardButtons() & QtWidgets.QMessageBox.StandardButton.Cancel ) + assert ( + dialog.defaultButton() + is dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel) + ) dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel).click() # the row's delete button path: the dialog appears, Cancel keeps @@ -3080,6 +3096,8 @@ def _accept_dialog(): timeout=BROADCAST_TIMEOUT, ) assert "q01.IF" not in gui.state.locks + # the closed dialog no longer sits on the GUI + assert gui.removalDialog is None # a parameter without Followers is removed with no dialog gui.removeParameter("other.x") @@ -3091,6 +3109,60 @@ def _accept_dialog(): gui.model.stopListener() +def test_removing_a_target_falls_back_to_the_client_side_followers( + qtbot, pm, second_client, server_port, monkeypatch +): + """When the Server call for ``followers_of`` fails, the confirmation + falls back to the client-side list computed from the state — locked + and unlocked alike, the Targets compared through ``relative_path``: + the dialog still names the Followers, and Cancel leaves the Server + untouched.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + timeout=BROADCAST_TIMEOUT, + ) + + def _raise(*args, **kwargs): + raise RuntimeError("the Server call failed") + + # the instance attribute shadows the Proxy Instrument's proxied + # method, so removeParameter's Server call fails + monkeypatch.setattr(gui.instrument, "followers_of", _raise) + + def _cancel_dialog(): + dialog = gui.removalDialog + assert dialog is not None + assert dialog.text().startswith( + "Removing q02.IF also removes the Locks of:" + ) + assert "q01.IF (locked)" in dialog.text() + dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel).click() + + # the row's delete button path: the dialog is built from the + # state, and Cancel keeps the Target and the Lock untouched + QtCore.QTimer.singleShot(0, _cancel_dialog) + _row_remove_button(gui, "q02.IF").click() + qtbot.wait(300) # a pm-lock-update would have arrived by now + assert pm.has_param("q02.IF") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + assert gui.state.locks.get("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + assert gui.removalDialog is None + finally: + gui.model.stopListener() + + def test_the_lock_shortcuts_arm_unlock_and_switch_tabs( qtbot, pm, second_client, server_port ): @@ -3168,11 +3240,11 @@ def test_the_lock_shortcuts_arm_unlock_and_switch_tabs( def test_a_parameter_update_for_an_unknown_row_recomputes_the_tints( qtbot, pm, second_client, server_port ): - """A parameter-update Broadcast for a parameter the model does not - know adds the row through the base update branch; the tints are - recomputed for it too (plan task 5.6), so the new row carries the - claiming Type's tint right away instead of staying untinted until the - next recompute.""" + """A parameter-update or parameter-call Broadcast for a parameter the + model does not know adds the row through the base update branch; the + tints are recomputed for it too (plan task 5.6), so the new row + carries the claiming Type's tint right away instead of staying + untinted until the next recompute.""" second_pm = _second_parameter_manager(second_client) pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") pm.add_type("qubit") @@ -3188,8 +3260,10 @@ def test_a_parameter_update_for_an_unknown_row_recomputes_the_tints( # Proxy (without reloading the model) makes the parameter resolve # when the update Broadcast arrives second_pm.add_parameter("q02.IF", initial_value=2.0, unit="Hz") + second_pm.add_parameter("q03.IF", initial_value=3.0, unit="Hz") pm.update() assert not _row_exists(gui, "q02.IF") + assert not _row_exists(gui, "q03.IF") gui.model.updateParameter( ParameterBroadcastBluePrint( @@ -3207,5 +3281,22 @@ def test_a_parameter_update_for_an_unknown_row_recomputes_the_tints( for item in _row_items(gui, "q02.IF"): assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint assert _row_items(gui, "q02.IF")[3].data(GUTTER_ROLE) == ["qubit"] + + # the base branch treats a parameter-call the same way: the row it + # adds is recomputed too + gui.model.updateParameter( + ParameterBroadcastBluePrint( + name=f"{PM_NAME}.q03.IF", + action=PARAMETER_CALL, + value=3.0, + unit="Hz", + ) + ) + qtbot.waitUntil( + lambda: _row_exists(gui, "q03.IF"), timeout=BROADCAST_TIMEOUT + ) + for item in _row_items(gui, "q03.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + assert _row_items(gui, "q03.IF")[3].data(GUTTER_ROLE) == ["qubit"] finally: gui.model.stopListener() From 32b5a1fb52975950cd43349e40ff6326911043a9 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 17:08:41 -0500 Subject: [PATCH 085/107] 5.6: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 46 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 3 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index a1509fa..0533759 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -939,3 +939,49 @@ The "Types" tab (`self.typesTab`) now holds a `TypesPane` (`self.typesPane`). It - The coder sat idle after its read pass, and one nudge got it going, the same pattern as in 5.3 and 5.4. - The coder moved the stray profile out of the repo root first and asked about it afterwards. The orchestrator had allowed the move because it could be undone. - Three permission requests were rejected: test-reviewer-qwen (twice) and plan-checker-glm asked to access opencode's temp directory under `/var/folders`, outside the repo. + +## 5.6 Delete-Target confirmation and polish — 2026-09-28 + +`ParameterManagerGui.removeParameter`, where both the row's delete button and the `delete_item` shortcut end up, now asks the Server for `followers_of(fullName)`. When the parameter has Followers, it shows a `QMessageBox` (`self.removalDialog`, object name `removalDialog`, title "Remove Target?") with the text "Removing also removes the Locks of:" and one ` (locked|unlocked)` line per Follower. The box has Ok and Cancel, Cancel is the default, and Cancel returns without calling the Server. Three REGISTRY entries were added to `gui/shortcuts.py`: `lock_to` (Ctrl+L, `_lock_current_item`), `unlock_item` (Ctrl+U, `_unlock_current_item`) and `show_types` (Ctrl+Shift+Y, `_toggle_tabs`). The task also closed the polish loose ends that 5.3–5.5 had logged for it. `resource.qrc` already listed `lock.svg`/`unlock.svg`, so it was not changed. This task closes Phase 5. + +### Commit by commit +- `f4923e3` The confirmation, the shortcuts, the polish and five new tests. The orchestrator's coder spec set seven readings. The main ones: + - If the `followers_of` call raises, the Followers are computed on the client instead, from `self.state.locks` (locked and unlocked alike, with Targets compared through `relative_path`). The locked/unlocked label on each line comes from the state. + - The shortcuts are appended after `toggle_locks`, so `jump_filter` stays the first key. `register_tooltip` puts the key in the tooltips of `view.lockToAction` and `view.unlockAction`. Ctrl+L and Ctrl+U do nothing on a submodule row or when nothing is selected, and Ctrl+U also does nothing unless the Lock is locked. None of the three keys collides with a REGISTRY entry or a hard-coded `QShortcut`. + - Polish: + - (a) `ModelParameterManager.updateParameter` now emits `structureChanged` when a `parameter-update` adds a row the model did not know. It compares the new `_has_row` before and after, so the new row gets its tint and gutter band right away. + - (b) A successful "Lock selection to…" resets `locksPanel`'s note, and a Type Lock re-target that skips nothing resets `typesPane`'s entries note. + - (c) With no Type selected, the three Types-tab strips are disabled and the labels read "parameters"/"instances". + - (d) Four stale docstrings were fixed: `LockArmStrip`, the arm-strip test, `LocksPanel`, and `test_a_deletion_broadcast_recomputes_the_tints`. + + The tests: + - `test_removing_a_target_confirms_and_cancel_keeps_the_server_untouched`, the plan's named test. It cancels on both delete paths and checks that `has_param`, `get_lock` and the state are unchanged after a wait. The Ok path then drops the Target, the Lock and the Lock column. A parameter without Followers is removed with no dialog. + - `test_the_lock_shortcuts_are_in_the_registry` and `test_the_lock_shortcuts_arm_unlock_and_switch_tabs`, which uses real `qtbot.keyClick`s. + - `test_lock_and_unlock_icons_ship_in_the_resources`, which runs without a server. + - `test_a_parameter_update_for_an_unknown_row_recomputes_the_tints`. + - One-line assertions for (b) and (c) in existing tests. + + The plan's manual end-to-end check was done as a script instead, because no worker can run a GUI by hand (see Loose ends). The scratch script `orchestration/5.6/e2e_param_manager.py` started an in-process Server on a free non-default port and built the GUI with the same wiring as `apps.parameterManagerScript`. A second Client then made changes, and all five checks passed ("e2e PASS 5/5"): the broadcast-listener probe, the tree row appearing, the value widget repainting, the Lock column and lock button updating, and the tint appearing. The script was deleted afterwards. Orchestrator run: ruff clean, 103 in the four named GUI files, 542 in the full suite. +- `e32078a` Fix from round 0, four items: + - New `test_removing_a_target_falls_back_to_the_client_side_followers`. It monkeypatches the Proxy Instrument's `followers_of` to raise, and checks that the dialog still names `q01.IF (locked)` and that Cancel leaves the Server untouched. test-reviewer-glm raised it as should-fix and test-reviewer-qwen as a nit. + - The named test gains a second Follower, `q03.IF`, whose Lock is unlocked. It asserts `q03.IF (unlocked)` and that Cancel is the default button. test-reviewer-qwen raised it as should-fix and test-reviewer-glm as a nit. test-reviewer-qwen's round-1 probe confirmed that under PyQt5 `defaultButton()` returns the widget, so the identity check is valid. + - `removalDialog` goes back to `None` once `exec()` returns, on both paths, as its docstring says. reviewer-glm, reviewer-qwen and plan-checker-qwen all raised it as a nit. The orchestrator sent it because the code stated something false. + - The `known_row`/`added_row` guard now treats `PARAMETER_CALL` like `PARAMETER_UPDATE`, since the base branch adds a row for both. The `structureChanged` docstring now names both cases, and the polish test also drives a `parameter-call` for `q03.IF`. Both general reviewers raised it as a nit. Reading 5(a) had named only `parameter-update`, and plan-checker-qwen noted the same gap as out of scope. + + All six approved in re-review. The round-1 fix list is empty. Orchestrator run: ruff clean, 104 in the four named GUI files, 543 in the full suite. + +### Dropped findings +- Not sent (test-reviewer-glm F3): Ctrl+L with no current row at all is untested (only the submodule-row no-op is), and the key hints in the two actions' tooltips are not asserted. +- Not sent (test-reviewer-qwen F3): the second Ctrl+U press, the one on an already-unlocked Follower, does not set the current row again first, so it might pass without testing anything. test-reviewer-glm noted that it could not fail anyway, because the Server's `unlock` of an unlocked Lock is a no-op. +- Round-1 nit (test-reviewer-qwen): the GUI test does not assert that `q03.IF`'s unlocked Lock is dropped on Ok. The 3.3 deletion-interplay tests cover that at the API layer. + +### Questions to Marcos +- The coder asked (through the orchestrator) what to do because macOS ignores `QMessageBox.setWindowTitle`, so asserting the title would fail there. → Answered by the orchestrator, not Marcos: keep the `setWindowTitle` call, and have the tests check the object name `removalDialog`, the text and the standard buttons instead. + +### Loose ends +- Flagged for Marcos: the plan asked for a manual end-to-end check, and a script stood in for it. A by-hand run is still worth doing once: `instrumentserver --port 5600`, then `instrumentserver-param-manager --port 5600`, and a second Client changing a value. The plan asks for the result to be noted under the task. It is recorded in `orchestration/5.6/decisions.md`, but it was not yet in the plan at `e32078a`, where the task still reads `[~]`. +- Still open from earlier tasks: 5.2's stale units after `set_type_parameter_unit`, and 5.4's text lost from a panel editor on rebuild. + +### Process notes +- The coder's first named-test run hung, because a modal dialog blocked pytest before the `QTimer`-driven canceler was in place. The coder profiled its own pytest with `sample` and killed it by pid. +- The orchestrator deleted run logs the coder had left behind. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index 1f32c33..a5fae65 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -600,13 +600,14 @@ Each task: what to build, files touched, acceptance, tests. One task per session `lock_type_parameter` shown in the pane's note line. Live update on `typeChanged`. Tests: `test_pm_gui.py` — create a Type and an Instance through the widgets; server state matches; second-client edits appear. -- [ ] **5.6 Delete-Target confirmation and polish.** Deleting a parameter (row delete button +- [x] **5.6 Delete-Target confirmation and polish.** Deleting a parameter (row delete button or shortcut) that has Followers pops a `QMessageBox` listing the Locks that will be removed (from `followers_of`) with OK/Cancel. Verify `resource.qrc` has `lock`/`unlock`; add shortcuts to `gui/shortcuts.py` for the new actions following its conventions; run `instrumentserver-param-manager` against a server on a non-default port and confirm live updates end to end (manual check, note the result under this task). Tests: `test_pm_gui.py` — the confirmation appears and Cancel leaves the server untouched. + Result (2026-09-28): the end-to-end check was run as a script equivalent to `instrumentserver-param-manager` against an in-process Server on a free non-default port: PASS 5/5 (listener probe, tree row appears, value widget repaints, Lock column and button update, tint appears). See `HISTORY_parameter_manager_redesign.md`, section 5.6. ### Phase 6 — Documentation From da417a96cc0567797893ee24e63602f3ffa1fe47 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 17:45:14 -0500 Subject: [PATCH 086/107] 6.1: User Guide Parameter Manager page with verification script and audit rows --- TEST_AUDIT.md | 4 + docs/user_guide/parameter_manager.md | 841 +++++++++++++++++- .../user_guide/verify_parameter_manager.py | 653 ++++++++++++++ 3 files changed, 1489 insertions(+), 9 deletions(-) create mode 100644 test/docs_verification/user_guide/verify_parameter_manager.py diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 4d7deae..88da3d1 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -45,6 +45,7 @@ States: | gui_features.md (future) | Parameter Manager GUI — live creation from another client | `ModelParameters.updateParameter`'s `parameter-creation` branch calls `instrument.update()` and then `nestedAttributeFromString` on the Proxy Instrument; a parameter another Client creates while the GUI is open raises `AttributeError` there (stale Proxy blueprint), so the row never appears | Found during the plan 5.1 work (coder probe, verified pre-existing by all six reviewers) | fixed | Fixed in plan task 5.5 by Marcos's decision (rule 6 exception): the branch resolves the element first and, on `AttributeError`, refreshes the stale Proxy blueprint and resolves again; regression tests live in `test/pytest/test_pm_gui.py` | | user_guide/parameter_manager.md (future) | Profiles — GUI start with no profile file | `ParameterManagerGui.__init__` calls `loadProfile`, which calls `switch_to_profile` with the combo's current text; with no profile file present `switch_to_profile` raises, so the GUI cannot be built until one profile exists | Found during the plan 5.1 work (coder probe) | gap | Pre-existing; not changed per plan rule 6 | | gui_features.md (future) | Parameter Manager GUI — `parameter-update` for a row with no widget | `ParameterManagerTreeView.onItemNewValue` indexes `self.delegate.parameters[itemName]` without a guard, so a `parameter-update` Broadcast for a row whose editor widget was never created raises `KeyError` and the value never shows | Found during the plan 5.3 round-0 review (reviewer-qwen) | gap | Pre-existing; not changed per plan rule 6; the new `ParameterManagerGui._on_item_new_value` guards with `.get` and logs instead of raising | +| user_guide/parameter_manager.md | Type Locks and Globals | From a Client, attribute access on the Proxy cannot reach the Globals submodule (QCoDeS reserves underscore names), and the dotted `set`/`get` through `Client.call("parameter_manager.set", ...)` is the working route to a Globals parameter | `section_type_locks_and_globals` in `verify_parameter_manager.py` | covered | The `Client.call` route is pinned over the wire in `test_pm_types.py` (the Globals-Target proxy tests, whose comments note the same shadowing); the Proxy's `AttributeError` on `_globals` itself has no direct pytest | ## Manual checks @@ -102,3 +103,6 @@ States: | `blueprints.InstrumentModuleBluePrint` | how_it_works.md | The docstring calls the Blueprint a "Spec" and does not explain that it describes parameters, methods, and submodules used to construct a Proxy Instrument | gap | Source docstring edits were explicitly excluded after the Client audit | | `client.proxy.ProxyInstrumentModule` | how_it_works.md | The docstring calls the proxy a "virtual module," contains the typo "instrument of submodule of instrument," and does not explain that calls are forwarded to the Server-owned instrument | gap | Source docstring edits were explicitly excluded after the Client audit | | `monitoring.listener.Listener` | how_it_works.md | Public abstract base class has no class docstring | gap | Add a short description of subscribing to Broadcasts and forwarding them to a sink | +| `params.ParameterGroup.has_param` | user_guide/parameter_manager.md (Hierarchical parameters) | No docstring at all; the page (and every client call site) relies on it returning whether the dotted path exists | gap | One line would do: "Whether a parameter exists at the dotted path." | +| `params.ParameterGroup.get` / `params.ParameterGroup.set` | user_guide/parameter_manager.md (Type Locks and Globals; Using it from measurement code) | No docstrings. The dotted-path form is the Server-side Parameter Manager's own `get`/`set` (the page reaches the Globals submodule with it through `Client.call`), but on a Proxy Instrument both names resolve to QCoDeS' deprecated local `InstrumentBase.get`/`set`, so the dotted form is unreachable through the Proxy; the docstrings should say which one they are | gap | The shadowing is noted in comments in `test_pm_types.py`; the methods themselves say nothing | +| `params.ParameterManager.fromFile` | user_guide/parameter_manager.md (Profiles and files) | The docstring documents `deleteMissing` as if it reached the loader, but `fromFile` never forwards it, so the load always runs with the default `True`; the `filePath=None` description names a file "parametermanager_parameters.json" that the code never uses (it loads the selected profile from the working directory) | gap | The behaviour half is already tracked in the tests table ("Profiles — loading a file"); the page documents the real behaviour with a note | diff --git a/docs/user_guide/parameter_manager.md b/docs/user_guide/parameter_manager.md index 4f664ed..ed388b1 100644 --- a/docs/user_guide/parameter_manager.md +++ b/docs/user_guide/parameter_manager.md @@ -1,14 +1,837 @@ # Parameter Manager -:::{admonition} 🚧 This page is planned, not yet written -:class: warning -It will be replaced with verified content as the documentation refactor progresses. +The Parameter Manager is instrumentserver's flagship Virtual Instrument: a +hierarchical, persistent, profile-aware store of experiment parameters. It +lives entirely in the Server, has no hardware behind it, and exists to be the +single source of truth for the numbers of your experiment: which frequency +each qubit uses, which gain each amplifier runs at, which LO every receiver +shares. + +You get one onto a Server like any other instrument, through a +[Client](client.md): + +```pycon +>>> from instrumentserver.client import Client +>>> cli = Client() +>>> pm = cli.find_or_create_instrument( +... "parameter_manager", +... "instrumentserver.params.ParameterManager", +... ) +``` + +Everything the Parameter Manager can do works from Python and from its GUI +alike, and the two stay in sync live. This page walks through the pieces in +order: plain hierarchical parameters, Types, Locks, Type Locks and Globals, +profile files, the GUI, and a full measurement-script example at the end. +Each section is a fresh start: the snippets assume an empty Parameter +Manager, so the outputs are exactly the ones you will see. + +## Concept + +The Parameter Manager is a Virtual Instrument: an instrument the Server owns +that has no hardware behind it. Its parameters are ordinary QCoDeS parameters +on the Server, which is what makes it the single source of truth: + +- Every Client and every GUI reads the same values, because there is only one + copy, and it lives in the Server. A Proxy Instrument never keeps a + diverging local value. +- Every change is announced as a Broadcast, so GUIs and Listeners follow + along without polling. The wire format is described in + [Broadcasts](../technical_guide/broadcasts.md). + +Two Clients see the same values with no refresh in between: + +```pycon +>>> pm_a = Client().find_or_create_instrument( +... "parameter_manager", +... "instrumentserver.params.ParameterManager", +... ) +>>> pm_a.add_parameter("q01.IF", initial_value=10e6, unit="Hz") +>>> pm_b = Client().get_instrument("parameter_manager") +>>> pm_b.q01.IF() +10000000.0 +>>> pm_a.q01.IF.set(11e6) +>>> pm_b.q01.IF() +11000000.0 +``` + +`pm_b` never refreshed anything: the read asked the Server, and the Server +holds the one value. When the change happened, the Server also put a +`parameter-update` Broadcast on its PUB socket, which is what the Parameter +Manager GUI listens to. When the Parameter Manager itself creates or edits +something, for example a Lock or a Type, it announces that with its own +Broadcast; the Technical Guide page above lists them all. + +## Hierarchical parameters + +Parameters live at dotted paths relative to the Parameter Manager, such as +`q01.readout.IF`. Every level of the path is a Parameter Group, a plain +container of parameters and further Parameter Groups, created on demand the +first time a path needs it. Only the root is the Parameter Manager itself; +the groups hold no files, no Types and no Locks. + +```pycon +>>> pm.add_parameter("q01.readout.IF", initial_value=20e6, unit="Hz") +>>> pm.add_parameter("q01.power", initial_value=-10, unit="dBm") +>>> pm.list() +['q01.readout.IF', 'q01.power'] +>>> pm.has_param("q01.readout.IF") +True +>>> pm.has_param("q01.readout.bw") +False +``` + +Through the Proxy Instrument, the hierarchy is attribute access, and +parameters read and set like any QCoDeS parameter: + +```pycon +>>> pm.q01.readout.IF() +20000000.0 +>>> pm.q01.readout.IF.set(21e6) +>>> pm.q01.readout.IF() +21000000.0 +>>> pm.q01.readout.IF.unit +'Hz' +``` + +:::{note} +The Proxy reflects the Server-side tree as it was when the Proxy was built or +last refreshed. When someone else creates parameters (another Client, or a +method that creates parameters as a side effect such as `add_instance`), call +`pm.update()` to pick them up. +::: + +`remove_parameter` deletes a parameter and prunes the Parameter Groups it +empties: + +```pycon +>>> pm.remove_parameter("q01.readout.IF") +>>> pm.list() +['q01.power'] +``` + +To keep emptied Parameter Groups around instead, pass `cleanup=False` to +`remove_parameter` and sweep them up later with `remove_empty_submodules()`. + +## Types + +Lab devices repeat: six qubits on one chip, four delivery channels per +module, each copy carrying the same parameters. The Parameter Manager makes +the repetition explicit with a Type. A Type is a named shape: a set of +relative parameter paths, each with a default value and a unit, plus Nested +Types required at named submodules. + +A Parameter Group that carries the whole shape is an Instance of that Type. +Nothing stores that membership anywhere: an Instance is found by shape, on +every query, and never registered. So a Parameter Group becomes an Instance +the moment it carries the shape, and stops being one the moment it does not. + +```pycon +>>> pm.add_type("qubit") +>>> pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") +>>> pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") +>>> pm.instances_of("qubit") +[] +>>> pm.add_instance("qubit", "q01") +>>> pm.add_instance("qubit", "q02") +>>> pm.instances_of("qubit") +['q01', 'q02'] +>>> pm.update() +>>> pm.q01.IF() +10000000.0 +>>> pm.q01.octave_gain() +10 +``` + +A fresh Type has no Instances, because nothing carries its shape yet. +`add_instance` writes the shape into a Parameter Group: every missing entry +is created with the Type's default value and unit, and parameters that exist +at those paths already are kept as they are. + +The shape-finding works in the other direction too. `q03` below was never +registered anywhere; it simply carries the shape, so it counts: + +```pycon +>>> pm.add_parameter("q03.IF", initial_value=99e6, unit="Hz") +>>> pm.add_parameter("q03.octave_gain", initial_value=1, unit="dB") +>>> pm.instances_of("qubit") +['q01', 'q02', 'q03'] +>>> pm.types_of("q03.IF") +['qubit'] +``` + +The same cuts both ways: delete a parameter an Instance needs, and that +submodule stops matching, with no other change. `q02` above stops being an +Instance the moment `q02.IF` is removed. + +### Editing a Type + +Adding an entry to a Type writes it into every Instance that lacks it, with +the entry's default and unit: + +```pycon +>>> pm.add_type_parameter("qubit", "window", default=0.5, unit="s") +>>> pm.update() +>>> pm.q03.window() +0.5 +``` + +Changing the default touches nobody who exists; only parameters created +later start with it: + +```pycon +>>> pm.set_type_parameter_default("qubit", "window", 1.0) +>>> pm.q01.window() +0.5 +>>> pm.add_instance("qubit", "q04") +>>> pm.update() +>>> pm.q04.window() +1.0 +``` + +`set_type_parameter_unit` changes the entry's unit and propagates it to that +parameter in every Instance. The unit is part of the shape, so a parameter +with the wrong unit keeps its Parameter Group out of the Instance list. + +### Nested Types + +A Type can require another Type at one of its submodules. A `qubit` needs a +`readout`; the Nested Type expands under the submodule it is required at: + +```pycon +>>> pm.add_type("readout") +>>> pm.add_type_parameter("readout", "bw", default=20e6, unit="Hz") +>>> pm.add_nested_type("qubit", "readout", "readout") +>>> pm.update() +>>> pm.q01.readout.bw() +20000000.0 +``` + +What a Type requires in total is its effective set: its own entries plus +every Nested Type's entries under their submodule names. `get_type` returns +it together with the rest of the definition: + +```pycon +>>> qubit = pm.get_type("qubit") +>>> qubit.nested +{'readout': 'readout'} +>>> qubit.effective["readout.bw"] +{'unit': 'Hz', 'from_type': 'readout'} +``` + +When several Types cover one parameter, the innermost Instance wins, then +the one with the larger effective set. That Claiming Type is what the GUI +tints a row with: + +```pycon +>>> pm.types_of("q01.readout.bw") +['readout', 'qubit'] +>>> pm.instances_of("readout") +['q01.readout', 'q02.readout', 'q03.readout', 'q04.readout'] +``` + +### Removing from a Type + +Removing an entry, a Nested Type or a whole Type leaves every parameter +alone. The submodules keep what they carry; matching simply follows the +shape, which the removal changed: + +```pycon +>>> pm.remove_type_parameter("qubit", "window") +>>> pm.has_param("q01.window") +True +>>> pm.remove_nested_type("qubit", "readout") +>>> pm.has_param("q01.readout.bw") +True +>>> pm.remove_type("qubit") +>>> pm.list_types() +['readout'] +>>> pm.has_param("q01.IF") +True +``` + +A Type that is still required as a Nested Type refuses to be removed, naming +the Types that nest it. + +## Locks + +Shared settings want one authoritative value. A Lock ties a parameter (the +Follower) to another parameter (its Target). A Lock has three states: + +- no Lock: the parameter behaves as a plain parameter; +- a Lock that is present but unlocked: the parameter answers `get` with its + own value, but remembers its Target so it can be locked again; +- a Lock that is locked: the parameter answers `get` with the Target's value + and refuses `set`. + +```pycon +>>> pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") +>>> pm.add_parameter("q01.IF", initial_value=5e6, unit="Hz") +>>> pm.lock("q01.IF", "q01Data.IF") +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager.q01Data.IF', locked=True, _class_type='PMLockBluePrint') +``` + +While locked, the Follower pulls the Target's value on every `get`. Nothing +is ever pushed into the Follower, and the Target knows nothing about its +Followers: + +```pycon +>>> pm.q01.IF() +10000000.0 +>>> pm.q01Data.IF.set(11e6) +>>> pm.q01.IF() +11000000.0 +``` + +A locked Follower refuses `set` with an error naming the Target. Over the +connection, Server-side errors arrive as a generic `Exception` (the +[Python Client](client.md) page describes this), and the message carries the +pair: + +```pycon +>>> try: +... pm.q01.IF.set(12e6) +... except Exception as exc: +... message = str(exc) +>>> "parameter_manager.q01.IF is locked to parameter_manager.q01Data.IF" in message +True +``` + +Locking never touches the Follower's own value. Unlocking exposes it again, +and relocking goes back to the Target; `toggle_lock` switches between the +two states: + +```pycon +>>> pm.unlock("q01.IF") +>>> pm.q01.IF() +5000000.0 +>>> pm.relock("q01.IF") +>>> pm.q01.IF() +11000000.0 +>>> pm.toggle_lock("q01.IF") +>>> pm.get_lock("q01.IF").locked +False +>>> pm.toggle_lock("q01.IF") +>>> pm.get_lock("q01.IF").locked +True +``` + +`remove_lock` removes a Lock entirely and forgets the Target. +`list_locks` reports every Lock in the Parameter Manager, and +`followers_of` names the Followers of one parameter: + +```pycon +>>> pm.followers_of("q01Data.IF") +['q01.IF'] +>>> pm.list_locks() +{'q01.IF': PMLockBluePrint(target='parameter_manager.q01Data.IF', locked=True, _class_type='PMLockBluePrint')} +``` + +A Lock never points at its own parameter, and its Target lives in the same +Parameter Manager. + +### Chains and cycles + +A Target may itself have a Lock, and each hop reads according to its own +state at read time: + +```pycon +>>> pm.add_parameter("q02.IF", initial_value=0, unit="Hz") +>>> pm.lock("q02.IF", "q01.IF") +>>> pm.q02.IF() +11000000.0 +>>> pm.unlock("q01.IF") +>>> pm.q02.IF() +5000000.0 +``` + +`q02.IF` is still locked, but it reads through `q01.IF`, which now answers +with its own value. Relock `q01.IF`, and `q02.IF` follows the Target again. + +Locks may chain but never cycle. A Lock that would close a cycle is refused, +with the whole chain in the error: + +```pycon +>>> try: +... pm.lock("q01Data.IF", "q02.IF") +... except Exception as exc: +... cycle_message = str(exc) +>>> cycle_message +'cannot lock parameter_manager.q01Data.IF to parameter_manager.q02.IF: cycle in Lock targets: parameter_manager.q02.IF -> parameter_manager.q01.IF -> parameter_manager.q01Data.IF' +``` + +Deleting a parameter removes the Locks that pointed at it; its Followers +become plain parameters and answer `get` with their own values again. A +Follower of a survivor keeps its Lock: + +```pycon +>>> pm.remove_parameter("q01Data.IF") +>>> pm.get_lock("q01.IF") +None +>>> pm.q01.IF() +5000000.0 +>>> pm.get_lock("q02.IF") +PMLockBluePrint(target='parameter_manager.q01.IF', locked=True, _class_type='PMLockBluePrint') +>>> pm.remove_lock("q02.IF") +>>> pm.list_locks() +{} +``` + +Every Lock method that changes a Lock emits one `pm-lock-update` Broadcast +per affected Follower, which is how the GUI and any other listener stay +current; [Broadcasts](../technical_guide/broadcasts.md) shows the payload. + +## Type Locks and Globals + +One Lock ties two parameters together. Usually you want one value shared by +every Instance of a Type: one LO frequency for all qubits. A Type Lock +declares that on a Type entry, and every Instance follows: + +```pycon +>>> pm.add_type("qubit") +>>> pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") +>>> pm.add_instance("qubit", "q01") +>>> pm.add_instance("qubit", "q02") +>>> pm.update() +>>> pm.lock_type_parameter("qubit", "IF") +[] +``` + +With no Target given, the Target is the Globals parameter +`_globals..`, created on demand with the entry's default value +and unit. Every current Instance gets an ordinary, locked Lock on it: + +```pycon +>>> pm.has_param("_globals.qubit.IF") +True +>>> cli.call("parameter_manager.get", "_globals.qubit.IF") +10000000.0 +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager._globals.qubit.IF', locked=True, _class_type='PMLockBluePrint') +``` + +Setting the Globals parameter moves every Follower at once: + +```pycon +>>> cli.call("parameter_manager.set", "_globals.qubit.IF", 12e6) +>>> pm.q01.IF() +12000000.0 +>>> pm.q02.IF() +12000000.0 +``` + +:::{note} +Names starting with an underscore are reserved by QCoDeS instruments, so +attribute access on the Proxy cannot reach the Globals submodule. The +Parameter Manager's own dotted `get` and `set` methods reach it; call them +through `Client.call`, as above. +::: + +An explicit Target names any parameter of the same Parameter Manager +instead: + +```pycon +>>> pm.add_parameter("lo.frequency", initial_value=1e6, unit="Hz") +>>> pm.remove_lock("q02.IF") # let q02 join the new Target below +>>> pm.lock_type_parameter("qubit", "IF", target="lo.frequency") +['q01.IF'] +``` + +The return value names the Instance parameters that were skipped, and the +Server logs a warning: `q01.IF` already carried a Lock on another Target +(the Globals parameter), and a Lock is never re-pointed behind its holder's +back. Everything else is locked to the new Target. Declaring the same Type +Lock again is how the GUI's lock all button re-applies it to everyone. + +`unlock_type_parameter` removes only the rule. The Locks it created stay +until they are removed individually, and Instances created after the removal +get no Lock: + +```pycon +>>> pm.unlock_type_parameter("qubit", "IF") +>>> pm.get_type("qubit").parameters["IF"]["target"] is None +True +>>> pm.get_lock("q02.IF") is not None +True +>>> pm.add_instance("qubit", "q03") +>>> pm.get_lock("q03.IF") +None +``` + +Globals itself is never an Instance of anything, and its parameters are +created on demand by the Type Lock, not through `add_parameter`, which +refuses the name with "the Globals submodule name is reserved". Otherwise a +Globals parameter is ordinary: it shows up in `list()`, can be read and set, +and is saved with the profile. + +## Profiles and files + +A Parameter Manager saves itself as a JSON profile document in the working +directory of the Server process, so the state survives restarts and can be +switched per experiment: + +```pycon +>>> pm.toFile() +``` + +The default file is `parameter_manager-parameter_manager.json`, and it holds +the version-2 document: + +```json +{ + "parameters": { + "parameter_manager.lo.frequency": { + "unit": "Hz", + "value": 5000000000.0 + }, + "parameter_manager.q01.IF": { + "lock": { + "locked": true, + "target": "parameter_manager.lo.frequency" + }, + "unit": "Hz", + "value": 10000000.0 + } + }, + "types": {}, + "version": 2 +} +``` + +Three things to read off that document: + +- Parameter keys are the full dotted paths with the instrument name in + front, the same form the Locks and the Type Lock Targets use. +- `value` is always the parameter's own value. A locked Follower saves its + own value plus its `lock`, never the Target's value, so unlocking exposes + what was saved. +- `lock` appears only on parameters that carry a Lock, in either state. The + `types` section holds the Type definitions, with each entry's default, + unit and Type Lock Target. + +`fromFile` loads a document back, restoring own values and Lock states: + +```pycon +>>> pm.lo.frequency.set(6e9) +>>> pm.fromFile() +>>> pm.lo.frequency() +5000000000.0 +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager.lo.frequency', locked=True, _class_type='PMLockBluePrint') +``` + +:::{note} +Loading removes parameters the document does not list; that is the +`deleteMissing` default of the reader underneath. `fromFile` always loads +with that default today, and the dictionary variant takes it explicitly: +`pm.fromParamDict(pm.toParamDict(), deleteMissing=False)` keeps parameters +the document does not list. +::: + +Any file named `parameter_manager-.json` in the working directory +is a profile. `refresh_profiles` re-reads the directory, `list_profiles` +reports what it found, and `switch_to_profile` moves between them: it saves +the profile being left, clears every parameter, Type and Lock, then loads +the profile you name: + +```pycon +>>> pm.toFile(name="cooldown") +>>> pm.refresh_profiles() +['parameter_manager-cooldown.json', 'parameter_manager-parameter_manager.json'] +>>> pm.unlock("q01.IF") +>>> pm.lo.frequency.set(8e9) +>>> pm.switch_to_profile("parameter_manager") +>>> pm.lo.frequency() +5000000000.0 +>>> pm.get_lock("q01.IF").locked +True +>>> pm.switch_to_profile("cooldown") +>>> pm.lo.frequency() +8000000000.0 +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager.lo.frequency', locked=False, _class_type='PMLockBluePrint') +``` + +:::{note} +A profile file without a top-level `version` key is the old flat map from +before Types and Locks existed. It loads as parameters only, and the Types +and Locks of the running Parameter Manager are left untouched. Saving always +writes the version-2 document, so one save over an old file upgrades it. +::: + +## The GUI + +The Parameter Manager GUI is one window with two tabs, Parameters and Types. +You can run it standalone: + +```{prompt} bash +instrumentserver-param-manager --port 5555 +``` + +The launcher connects a Client to the Server on that port, creates the +Parameter Manager named `parameter_manager` if it does not exist yet, and +opens the window. `--name` chooses a different Parameter Manager. The same +widget is embedded in the Server window, which shows it for a Parameter +Manager in its Station; [the Server](server.md) covers launching, and +[GUI features](gui_features.md) describes the patterns shared by every +instrument window: starring, trashing, filtering, and the detachable tabs. + +### The Parameters tab + +The Parameters tab is the tree of parameters: name, unit, a value editor per +row, and the strip at the bottom to add a parameter. Three things are +specific to the Parameter Manager: + +- **Tints and gutter bands.** Every parameter of an Instance is tinted with + the colour of its Claiming Type, and the thin coloured band at the left + edge stacks one segment per Type covering the row, innermost first. When a + Client edits a Type, the tints follow live, because the Parameter Manager + emits a `pm-type-update` Broadcast. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The Parameters tab with two Types tinting their rows: the parameter tree +with `qubit` rows in one tint and `readout` rows in another, the coloured +gutter bands stacked at the left edge, the "locked to" column showing +`locked to _globals.qubit.IF` on the locked rows, and the lock button on one +of them. Light theme: +`docs/_static/user_guide/parameter_manager/tree_tints_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/tree_tints_dark.png`. ::: -This page will cover: +% +% ```{image} ../_static/user_guide/parameter_manager/tree_tints_light.png +% :class: only-light +% :alt: The Parameters tab: the parameter tree with Type tints and gutter bands +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/tree_tints_dark.png +% :class: only-dark +% :alt: The Parameters tab: the parameter tree with Type tints and gutter bands +% ``` + +- **The "locked to" column.** A Follower shows `locked to ` while + its Lock is locked and `unlocked · ` while unlocked; a parameter + that is a Target shows `target ×N` for its N Followers. The lock button on + the row, purple while locked, toggles the Lock. +- **The context menu.** Right-clicking a row offers "Lock to…" and "Unlock" + beside the usual actions. + +"Lock to…" arms the Target picker: a strip appears under the toolbar, naming +the Follower, with a line edit that completes over every parameter path +(ranked so that paths in the Follower's own submodule come first) and a +Cancel button. Click a tree row or complete a path to pick the Target. If +the Server refuses, for a cycle for example, the strip shows the error. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The arm strip in action: the strip under the toolbar reading +"Target for q01.IF", the line edit showing a typed partial path with the +completion popup open over the candidate paths, and the Cancel button at +its right. Light theme: +`docs/_static/user_guide/parameter_manager/arm_strip_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/arm_strip_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/arm_strip_light.png +% :class: only-light +% :alt: The arm strip picking a Target under the toolbar +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/arm_strip_dark.png +% :class: only-dark +% :alt: The arm strip picking a Target under the toolbar +% ``` + +### The Locks panel + +The lock icon in the toolbar, or Ctrl+Shift+L, opens the Locks panel next to +the tree. It shows the same Locks from the Target side: one row per Target, +with each Target's Followers nested beneath it, recursively for chains. Type +Lock Targets come first and are labelled with their Type, as +`[type: qubit] _globals.qubit.IF`. + +Each row carries what it needs. A Target row has a value editor, and setting +it repaints every Follower watching. A locked Follower row shows its value +read-only, with a lock or relock toggle and a remove button. A Type Lock row +has lock all, which re-applies the Type Lock to every Instance, and remove +rule, which removes only the rule and leaves the Locks. "Lock selection +to…" arms the picker for the row currently selected in the tree. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The Locks panel open next to the tree: the Type Lock Target row labelled +`[type: qubit] _globals.qubit.IF` with its value editor, two Follower rows +nested beneath it with read-only values and their toggle and remove buttons, +and the "Lock selection to…" button with the selected-parameter label at the +bottom. Light theme: +`docs/_static/user_guide/parameter_manager/locks_panel_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/locks_panel_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/locks_panel_light.png +% :class: only-light +% :alt: The Locks panel with a Type Lock Target and its Followers +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/locks_panel_dark.png +% :class: only-dark +% :alt: The Locks panel with a Type Lock Target and its Followers +% ``` + +### The Types tab + +The Types tab, or Ctrl+Shift+Y, keeps the structure work in one place, in +three panes: + +- Left: the list of Types, each with its number of Instances and parameters, + tinted in the Type's colour, and the "New type:" strip below. +- Top right: the selected Type's entries as a tree. An entry of the Type + itself has an editable default, a Remove button, and the Type Lock toggle + in the "locked to" column, which doubles as the re-target button while the + entry is locked. Entries that come from a Nested Type are read-only and + say "defined by "; submodule rows show which Type they require and + can drop the requirement. The "Add to type" and "Nested type" strips below + add entries and Nested Type requirements. +- Bottom right: the Instances of the selected Type, each with its parameter + count and the other Types it also carries. "Show" jumps to the Parameters + tab and selects the Instance; the "New instance:" strip adds one. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The Types tab with a Type selected: the tinted type list on the left, the +entries tree in the top right pane with an editable default, a Type Lock +toggle and a "defined by readout" row, the two strips below it, and the +Instances pane in the bottom right with one Instance row and its Show +button. Light theme: +`docs/_static/user_guide/parameter_manager/types_tab_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/types_tab_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/types_tab_light.png +% :class: only-light +% :alt: The Types tab with its three panes +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/types_tab_dark.png +% :class: only-dark +% :alt: The Types tab with its three panes +% ``` + +### Deleting a Target + +Deleting a parameter that others follow is the one destructive action with a +safety net: the Parameter Manager names every Follower whose Lock will +vanish and asks for confirmation. Cancel leaves the Server untouched; a +parameter without Followers is deleted without asking. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The "Remove Target?" confirmation dialog for a parameter with two +Followers: the question text listing each Follower with its Lock state, +with Cancel as the default button. Light theme: +`docs/_static/user_guide/parameter_manager/delete_confirmation_light.png`, +dark theme: +`docs/_static/user_guide/parameter_manager/delete_confirmation_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/delete_confirmation_light.png +% :class: only-light +% :alt: The Remove Target confirmation dialog +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/delete_confirmation_dark.png +% :class: only-dark +% :alt: The Remove Target confirmation dialog +% ``` + +### Keyboard shortcuts + +The shortcuts below matter most in this window; the defaults come from the +GUI's shortcut registry, and [GUI features](gui_features.md) shows how to +rebind them. + +| Action | Default keys | What it does | +| --- | --- | --- | +| Delete parameter | `Ctrl+Backspace` | Delete the selected parameter | +| Lock to… | `Ctrl+L` | Lock the selected parameter to… (pick a Target) | +| Unlock | `Ctrl+U` | Unlock the selected parameter | +| Show the Locks panel | `Ctrl+Shift+L` | Show or hide the Locks panel | +| Switch tabs | `Ctrl+Shift+Y` | Switch between the Parameters and Types tabs | +| Add parameter | `Ctrl+N` | Jump cursor to the add parameter bar | +| Load parameters | `Ctrl+Shift+O` | Load parameters from JSON file | +| Save parameters | `Ctrl+Shift+S` | Save parameters to JSON file | +| Refresh all | `Ctrl+Shift+R` | Refresh all parameters from instrument | + +## Using it from measurement code + +A measurement script treats the Parameter Manager like any instrument: find +it or create it, then read and set through the Proxy Instrument. One-time +setup declares the shape, and a Type Lock shares one value across every +Instance: + +```pycon +>>> from instrumentserver.client import Client +>>> cli = Client() +>>> pm = cli.find_or_create_instrument( +... "parameter_manager", +... "instrumentserver.params.ParameterManager", +... ) +>>> pm.add_parameter("power", initial_value=-10, unit="dBm") +>>> pm.add_type("qubit") +>>> pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") +>>> pm.add_instance("qubit", "q0") +>>> pm.add_instance("qubit", "q1") +>>> pm.update() +>>> pm.lock_type_parameter("qubit", "IF") +[] +``` + +The sweep sets the Globals Target and reads the Followers; both qubits move +together, and any GUI watching follows live: + +```pycon +>>> for if_hz in (10e6, 11e6, 12e6): +... cli.call("parameter_manager.set", "_globals.qubit.IF", if_hz) +... print(pm.q0.IF(), pm.q1.IF()) +10000000.0 10000000.0 +11000000.0 11000000.0 +12000000.0 12000000.0 +``` + +When one qubit has to deviate, unlock it first: setting a locked Follower +raises, naming its Target. The Follower keeps its own value the whole time, +so unlocking exposes it, and `relock` rejoins the Target: + +```pycon +>>> try: +... pm.q0.IF.set(13e6) +... except Exception as exc: +... "is locked to" in str(exc) +True +>>> pm.unlock("q0.IF") +>>> pm.q0.IF.set(13e6) +>>> pm.q0.IF() +13000000.0 +>>> pm.q1.IF() +12000000.0 +>>> pm.relock("q0.IF") +>>> pm.q0.IF() +12000000.0 +``` + +At the end of the experiment, save the profile, and the next run starts +where this one ended: + +```pycon +>>> pm.toFile() +``` -- Concept: the flagship Virtual Instrument; single source of truth -- Hierarchical parameters: add / remove / nesting -- Persistence: JSON files; profiles (refresh / switch) -- The Parameter Manager GUI and the `instrumentserver-param-manager` launcher -- Using it from measurement code +The [Python Client](client.md) page covers the Client's connection lifecycle +and error handling, and [the Server](server.md) explains where profile files +end up when the Server runs somewhere else. diff --git a/test/docs_verification/user_guide/verify_parameter_manager.py b/test/docs_verification/user_guide/verify_parameter_manager.py new file mode 100644 index 0000000..41f970f --- /dev/null +++ b/test/docs_verification/user_guide/verify_parameter_manager.py @@ -0,0 +1,653 @@ +"""Verification script for docs/user_guide/parameter_manager.md. + +One section below per page section, in page order (see +test/docs_verification/README.md for conventions). Asserts every behavioral +claim the page makes; exits 0 on success. + +A Parameter Manager writes its profile files into the working directory of +the process it lives in, so every section runs inside a throwaway working +directory created (and removed again) under this script's folder; no profile +file is ever left in the repository. + +GUI claims (the tab layout, tints and gutter bands, the Lock column and lock +button, the context menu, the arm strip flow, the Locks panel, the Types tab +panes, and the delete-Target confirmation dialog) cannot be asserted from a +script. They are verified manually (the plan's task 5.6 records the +end-to-end GUI check) and captured in the page's screenshots. What the GUI +section states about keyboard shortcuts is asserted here against the GUI's +shortcut registry. +""" + +import json +import logging +import os +import shutil +import sys +import tempfile +from contextlib import contextmanager +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from helpers import capture_broadcasts, client, server + +from instrumentserver.blueprints import PMLockBluePrint + +PM_CLASS = "instrumentserver.params.ParameterManager" +PM_NAME = "parameter_manager" + + +@contextmanager +def workspace(): + """Run one section in a throwaway working directory. + + The in-process Server (and with it every Parameter Manager it hosts) + writes its profile files into the working directory. The directory is + created under this script's folder and removed again on exit. + """ + old = os.getcwd() + path = Path(tempfile.mkdtemp(prefix="verify_pm_", dir=Path(__file__).parent)) + os.chdir(path) + try: + yield path + finally: + os.chdir(old) + shutil.rmtree(path, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Section: Concept +# +# Page claims: the Parameter Manager is the flagship Virtual Instrument, the +# single source of truth for experiment parameters; every Client sees the +# same values without refreshing; a change reaches every GUI live through a +# Broadcast. +# --------------------------------------------------------------------------- +def section_concept() -> None: + with workspace(), server(): + with client() as cli_a, client() as cli_b: + pm_a = cli_a.find_or_create_instrument(PM_NAME, PM_CLASS) + pm_a.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + assert pm_a.q01.IF() == 10000000.0 + + # a second Client reads the same value: one source of truth + pm_b = cli_b.get_instrument(PM_NAME) + assert pm_b.q01.IF() == 10000000.0 + + # the change goes out as a Broadcast (which is what makes GUIs + # follow along live, without polling) + with capture_broadcasts([PM_NAME]) as cap: + pm_a.q01.IF.set(11e6) + messages = cap.wait_for(1) + text = repr(messages) + assert "parameter-update" in text, text + assert "parameter_manager.q01.IF" in text, text + + # and the second Client sees the new value on its next read + assert pm_b.q01.IF() == 11000000.0 + print("section_concept: OK") + + +# --------------------------------------------------------------------------- +# Section: Hierarchical parameters +# +# Page claims: dotted paths address parameters in nested Parameter Groups, +# which are created on demand; add_parameter / remove_parameter / list / +# has_param manage the tree; get and set work through dotted paths and +# through Proxy attribute access; removing a parameter leaves emptied +# Parameter Groups in place only with cleanup=False, and +# remove_empty_submodules prunes them. +# --------------------------------------------------------------------------- +def section_hierarchical_parameters() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + + # dotted names create the Parameter Groups on the way + pm.add_parameter("q01.readout.IF", initial_value=20e6, unit="Hz") + pm.add_parameter("q01.power", initial_value=-10, unit="dBm") + assert pm.list() == ["q01.readout.IF", "q01.power"] + assert pm.has_param("q01.readout.IF") + assert not pm.has_param("q01.readout.bw") + + # the Proxy reflects the hierarchy: attribute access reaches the + # nested parameters + assert pm.q01.readout.IF() == 20000000.0 + pm.q01.power.set(-5) + assert pm.q01.power() == -5 + pm.q01.readout.IF.set(21e6) + assert pm.q01.readout.IF() == 21000000.0 + assert pm.q01.readout.IF.unit == "Hz" + + # cleanup=False leaves the emptied Parameter Group in the tree + pm.remove_parameter("q01.readout.IF", cleanup=False) + pm.update() + assert not pm.has_param("q01.readout.IF") + assert pm.q01.readout is not None + + # ... and remove_empty_submodules prunes every empty group + pm.remove_parameter("q01.power", cleanup=False) + pm.remove_empty_submodules() + pm.update() + assert pm.list() == [] + try: + pm.q01 + gone = False + except AttributeError: + gone = True + assert gone + print("section_hierarchical_parameters: OK") + + +# --------------------------------------------------------------------------- +# Section: Types +# +# Page claims: a Type is a named shape (relative paths with defaults and +# units, plus Nested Types); Instances are duck-typed, recomputed on demand, +# never stored; add_type_parameter writes the entry into every Instance +# lacking it; set_type_parameter_default only affects Instances created +# later; set_type_parameter_unit propagates to every Instance; Nested Types +# expand under their submodule; add_instance writes the shape into a new +# Parameter Group; instances_of / types_of answer on demand; removing an +# entry, a Nested Type or a Type leaves the parameters alone. +# --------------------------------------------------------------------------- +def section_types() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + + # a fresh Type has no Instances: nothing carries its shape yet + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + assert pm.instances_of("qubit") == [] + + # add_instance writes the shape into a new Parameter Group + pm.add_instance("qubit", "q01") + pm.add_instance("qubit", "q02") + assert pm.instances_of("qubit") == ["q01", "q02"] + pm.update() + assert pm.q01.IF() == 10000000.0 + assert pm.q01.octave_gain() == 10 + assert pm.q01.IF.unit == "Hz" + + # duck-typed: a submodule that carries the shape is an Instance, + # no matter how it got its parameters + pm.add_parameter("q03.IF", initial_value=99e6, unit="Hz") + pm.add_parameter("q03.octave_gain", initial_value=1, unit="dB") + assert pm.instances_of("qubit") == ["q01", "q02", "q03"] + assert pm.types_of("q03.IF") == ["qubit"] + + # a new entry is written into every Instance lacking it + pm.add_type_parameter("qubit", "window", default=0.5, unit="s") + pm.update() + assert pm.q01.window() == 0.5 + assert pm.q03.window() == 0.5 + + # a new default only affects Instances created later + pm.set_type_parameter_default("qubit", "window", 1.0) + assert pm.q01.window() == 0.5 + pm.add_instance("qubit", "q04") + pm.update() + assert pm.q04.window() == 1.0 + + # a new unit propagates to that parameter in every Instance + pm.set_type_parameter_unit("qubit", "IF", "V") + with client() as cli_fresh: + fresh = cli_fresh.get_instrument(PM_NAME) + assert fresh.q01.IF.unit == "V" + assert fresh.q03.IF.unit == "V" + + # a Nested Type expands under its submodule, in the effective set + pm.add_type("readout") + pm.add_type_parameter("readout", "bw", default=20e6, unit="Hz") + pm.add_nested_type("qubit", "readout", "readout") + pm.update() + assert pm.q01.readout.bw() == 20000000.0 + blueprint = pm.get_type("qubit") + assert blueprint.nested == {"readout": "readout"} + assert blueprint.effective["readout.bw"] == { + "unit": "Hz", + "from_type": "readout", + } + # the Nested Type claims its entries; the innermost Type wins + assert pm.types_of("q01.readout.bw") == ["readout", "qubit"] + assert pm.instances_of("readout") == [ + "q01.readout", + "q02.readout", + "q03.readout", + "q04.readout", + ] + + # duck-typing cuts both ways: deleting a required parameter makes + # the submodule stop matching, and nothing else changes + pm.remove_parameter("q02.IF") + assert pm.has_param("q02.octave_gain") + assert pm.instances_of("qubit") == ["q01", "q03", "q04"] + + # removing an entry from the Type leaves the parameters alone, + # and the Instances keep matching: the required shape only shrank + pm.remove_type_parameter("qubit", "window") + assert pm.has_param("q01.window") + assert pm.has_param("q04.window") + assert pm.instances_of("qubit") == ["q01", "q03", "q04"] + + # same for a Nested Type and for the Type itself: the parameters + # stay whatever happens to the Type + pm.remove_nested_type("qubit", "readout") + assert pm.has_param("q01.readout.bw") + assert "q01.readout" in pm.instances_of("readout") + pm.remove_type("qubit") + assert pm.list_types() == ["readout"] + assert pm.has_param("q01.IF") + assert pm.q01.IF() == 10000000.0 + print("section_types: OK") + + +# --------------------------------------------------------------------------- +# Section: Locks +# +# Page claims: a Lock has three states (no Lock; Lock present but unlocked, +# remembering its Target; Lock present and locked); a locked Follower +# answers get with the Target's value and refuses set with an error naming +# the Target; values are pulled on get, so setting the Target is enough and +# unlocking exposes the Follower's own value again; Locks chain and each hop +# reads by its own state; cycles are refused with an error listing the +# chain; get_lock / list_locks / followers_of report the state; deleting a +# Target removes the Locks that pointed at it; the Parameter Manager emits a +# pm-lock-update Broadcast for each affected Follower. +# --------------------------------------------------------------------------- +def section_locks() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e6, unit="Hz") + pm.add_parameter("q02.IF", initial_value=0, unit="Hz") + + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + messages = cap.wait_for(1) + text = repr(messages) + assert "pm-lock-update" in text, text + assert "parameter_manager.q01.IF" in text, text + + # locked: the Follower answers get with the Target's value + lock = pm.get_lock("q01.IF") + assert lock.target == "parameter_manager.q01Data.IF" + assert lock.locked + assert pm.q01.IF() == 10000000.0 + + # pull on get: setting the Target is enough, nothing is pushed + pm.q01Data.IF.set(11e6) + assert pm.q01.IF() == 11000000.0 + + # set on a locked Follower raises, naming Follower and Target + try: + pm.q01.IF.set(12e6) + set_error = None + except Exception as exc: # noqa: BLE001 + set_error = str(exc) + assert set_error is not None + assert ( + "parameter_manager.q01.IF is locked to " + "parameter_manager.q01Data.IF" in set_error + ), set_error + + # unlocking exposes the Follower's own value again + pm.unlock("q01.IF") + assert pm.get_lock("q01.IF").locked is False + assert pm.q01.IF() == 5000000.0 + pm.relock("q01.IF") + assert pm.q01.IF() == 11000000.0 + pm.toggle_lock("q01.IF") + assert pm.get_lock("q01.IF").locked is False + pm.toggle_lock("q01.IF") + assert pm.get_lock("q01.IF").locked is True + + # bookkeeping: followers_of and list_locks + assert pm.followers_of("q01Data.IF") == ["q01.IF"] + assert list(pm.list_locks()) == ["q01.IF"] + + # chains: each hop reads according to its own state + pm.lock("q02.IF", "q01.IF") + assert pm.q02.IF() == 11000000.0 + pm.q01Data.IF.set(12e6) + assert pm.q02.IF() == 12000000.0 + pm.unlock("q01.IF") + assert pm.q01.IF() == 5000000.0 + assert pm.q02.IF() == 5000000.0 + pm.relock("q01.IF") + assert pm.q02.IF() == 12000000.0 + assert sorted(pm.list_locks()) == ["q01.IF", "q02.IF"] + + # cycles are refused, with the whole chain in the error + try: + pm.lock("q01Data.IF", "q02.IF") + cycle_error = None + except Exception as exc: # noqa: BLE001 + cycle_error = str(exc) + assert cycle_error is not None + assert "cycle in Lock targets" in cycle_error, cycle_error + for path in ( + "parameter_manager.q02.IF", + "parameter_manager.q01.IF", + "parameter_manager.q01Data.IF", + ): + assert path in cycle_error, cycle_error + + # a self-lock is refused too + try: + pm.lock("q01.IF", "q01.IF") + self_error = None + except Exception as exc: # noqa: BLE001 + self_error = str(exc) + assert self_error is not None + assert "cannot lock" in self_error, self_error + + # deleting a Target removes the Locks that pointed at it; the + # Followers become plain parameters + pm.remove_parameter("q01Data.IF") + assert pm.get_lock("q01.IF") is None + assert pm.q01.IF() == 5000000.0 + # q02.IF's Lock pointed at q01.IF, which still exists + assert pm.get_lock("q02.IF") is not None + assert pm.q02.IF() == 5000000.0 + pm.remove_lock("q02.IF") + assert pm.list_locks() == {} + print("section_locks: OK") + + +# --------------------------------------------------------------------------- +# Section: Type Locks and Globals +# +# Page claims: lock_type_parameter puts a locked Lock on the entry's +# parameter in every current Instance; with no explicit Target the Target is +# the Globals parameter _globals.., created on demand with the +# entry's default and unit; setting the Globals parameter moves every +# Follower; Instance parameters already locked to another Target are +# skipped, returned and logged; unlock_type_parameter removes only the rule, +# the Locks it created stay; new Instances created after the rule is removed +# get no Lock; Globals is never an Instance, and parameters under it cannot +# be created through add_parameter. +# --------------------------------------------------------------------------- +def section_type_locks_and_globals() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_instance("qubit", "q01") + pm.add_instance("qubit", "q02") + pm.update() + + # the default Target: the Globals parameter, created on demand + assert pm.lock_type_parameter("qubit", "IF") == [] + assert pm.has_param("_globals.qubit.IF") + assert "_globals.qubit.IF" in pm.list() + # names starting with an underscore are reserved by QCoDeS, so + # attribute access cannot reach Globals; the Parameter Manager's + # own dotted get and set do, through Client.call + assert cli.call("parameter_manager.get", "_globals.qubit.IF") == 10000000.0 + for instance in ("q01", "q02"): + lock = pm.get_lock(f"{instance}.IF") + assert lock.target == "parameter_manager._globals.qubit.IF" + assert lock.locked + # Globals is never an Instance + assert pm.instances_of("qubit") == ["q01", "q02"] + + # setting the Globals parameter moves every Follower + cli.call("parameter_manager.set", "_globals.qubit.IF", 12e6) + assert pm.q01.IF() == 12000000.0 + assert pm.q02.IF() == 12000000.0 + + # an explicit Target; an Instance already locked to another + # Target is skipped, returned and logged + pm.add_parameter("lo.frequency", initial_value=1e6, unit="Hz") + pm.remove_lock("q02.IF") + records = [] + handler = logging.Handler() + handler.emit = lambda record: records.append(record.getMessage()) + logger = logging.getLogger("instrumentserver.params") + logger.addHandler(handler) + try: + skipped = pm.lock_type_parameter("qubit", "IF", target="lo.frequency") + finally: + logger.removeHandler(handler) + assert skipped == ["q01.IF"], skipped + assert any("skipped" in message for message in records), records + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager.lo.frequency", locked=True + ) + assert pm.q02.IF() == 1000000.0 + assert pm.q01.IF() == 12000000.0 + + # removing the Type Lock removes only the rule + pm.unlock_type_parameter("qubit", "IF") + assert pm.get_type("qubit").parameters["IF"]["target"] is None + assert pm.get_lock("q02.IF") is not None + # ... so Instances created afterwards get no Lock + pm.add_instance("qubit", "q03") + assert pm.get_lock("q03.IF") is None + + # Globals parameters are created on demand by the Type Lock, not + # through the public API + try: + pm.add_parameter("_globals.extra", initial_value=1) + globals_error = None + except Exception as exc: # noqa: BLE001 + globals_error = str(exc) + assert globals_error is not None + assert "reserved" in globals_error, globals_error + print("section_type_locks_and_globals: OK") + + +# --------------------------------------------------------------------------- +# Section: Profiles and files +# +# Page claims: toFile writes the version-2 profile document (values are the +# parameters' own values, lock appears only on Followers); fromFile loads it +# again, with deleteMissing removing parameters the file does not list; +# switch_to_profile saves the current profile, then clears everything, then +# loads the new one; list_profiles / refresh_profiles report the profile +# files of the working directory; a file without a version key is the legacy +# flat map and loads as parameters only; saving always writes version 2. +# --------------------------------------------------------------------------- +def section_profiles_and_files() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("lo.frequency", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + + # a Follower, so the profile file shows a lock entry + pm.lock("q01.IF", "lo.frequency") + pm.toFile() + + profile = Path.cwd() / "parameter_manager-parameter_manager.json" + assert profile.exists() + document = json.loads(profile.read_text()) + assert document["version"] == 2 + assert sorted(document) == ["parameters", "types", "version"] + assert document["parameters"]["parameter_manager.q01.IF"] == { + "unit": "Hz", + "value": 10000000.0, + "lock": {"target": "parameter_manager.lo.frequency", "locked": True}, + } + # the Target stores no lock entry, and its own current value + assert document["parameters"]["parameter_manager.lo.frequency"] == { + "unit": "Hz", + "value": 5000000000.0, + } + assert document["types"] == {} + + # fromFile restores the saved state: the own value of the + # Follower and its Lock, so it reads the Target again + pm.lo.frequency.set(6e9) + pm.fromFile() + assert pm.lo.frequency() == 5000000000.0 + assert pm.q01.IF() == 5000000000.0 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.lo.frequency", locked=True + ) + + # deleteMissing controls whether parameters the document does + # not list are removed. fromFile always loads with the default + # (True); the dictionary variant takes it explicitly. + pm.add_parameter("temp.extra", initial_value=1) + pm.fromParamDict(pm.toParamDict(), deleteMissing=False) + assert pm.has_param("temp.extra") + pm.fromFile() + assert not pm.has_param("temp.extra") + + # a second profile: saving to a profile file also selects it; + # a plain name lands in the working directory as + # parameter_manager-cooldown.json + pm.toFile(name="cooldown") + pm.refresh_profiles() + assert sorted(pm.list_profiles()) == [ + "parameter_manager-cooldown.json", + "parameter_manager-parameter_manager.json", + ] + + # the cooldown profile holds a different state: unlocked, and a + # different value + pm.unlock("q01.IF") + pm.lo.frequency.set(8e9) + + # the save selected cooldown, so switch back to the default + # profile first; the state comes back from the file + pm.switch_to_profile("parameter_manager") + assert pm.lo.frequency() == 5000000000.0 + assert pm.get_lock("q01.IF").locked is True + + # change the live state, then switch: the leaving profile is + # saved first, everything is cleared, then cooldown is loaded + pm.lo.frequency.set(9e9) + pm.switch_to_profile("cooldown") + assert pm.lo.frequency() == 8000000000.0 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.lo.frequency", locked=False + ) + + # a file without a version key is the legacy flat map: it loads + # as parameters only + legacy = Path.cwd() / "parameter_manager-legacy.json" + legacy.write_text( + json.dumps({"parameter_manager.old_param": {"value": 123, "unit": "M"}}) + ) + pm.fromFile(str(legacy)) + assert not pm.has_param("lo.frequency") + pm.update() + assert pm.old_param() == 123 + + # saving always writes version 2, even over a legacy file; and + # loading a profile file selected it, so the save lands there + pm.toFile() + assert json.loads(legacy.read_text())["version"] == 2 + print("section_profiles_and_files: OK") + + +# --------------------------------------------------------------------------- +# Section: The GUI +# +# The GUI's behaviour (the Parameters and Types tabs, tints and gutter +# bands, the "locked to" column and per-row lock button, the context menu, +# the arm strip, the Locks panel, the Types tab panes, and the delete-Target +# confirmation) cannot be asserted from a script; it is verified manually +# (plan task 5.6 records the end-to-end GUI check) and captured in the +# page's screenshots. The keyboard shortcuts the page lists come from the +# GUI's shortcut registry, and that is asserted here. +# --------------------------------------------------------------------------- +def section_the_gui() -> None: + from instrumentserver.gui.shortcuts import KeyboardShortcutManager + + registry = KeyboardShortcutManager.REGISTRY + expected = { + "delete_item": ("Ctrl+Backspace", "Delete the selected parameter"), + "toggle_locks": ("Ctrl+Shift+L", "Show or hide the Locks panel"), + "lock_to": ("Ctrl+L", "Lock the selected parameter to… (pick a Target)"), + "unlock_item": ("Ctrl+U", "Unlock the selected parameter"), + "show_types": ( + "Ctrl+Shift+Y", + "Switch between the Parameters and Types tabs", + ), + "add_item": ("Ctrl+N", "Jump cursor to the add parameter bar"), + "load_items": ("Ctrl+Shift+O", "Load parameters from JSON file"), + "save_items": ("Ctrl+Shift+S", "Save parameters to JSON file"), + "refresh_all": ("Ctrl+Shift+R", "Refresh all parameters from instrument"), + } + for action_id, entry in expected.items(): + assert registry[action_id] == entry, (action_id, registry[action_id]) + print("section_the_gui: OK") + + +# --------------------------------------------------------------------------- +# Section: Using it from measurement code +# +# Page claims: a measurement script finds (or creates) the Parameter Manager +# through a Client; a Type keeps repeated structures complete and a Type +# Lock shares one value; the sweep sets the Globals Target and reads the +# Followers through the Proxy; setting a locked Follower raises, so a +# deviating parameter is unlocked first and rejoins with relock; the profile +# is saved at the end of the experiment. +# --------------------------------------------------------------------------- +def section_using_it_from_measurement_code() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + + # one-time setup: the parameters the experiment needs + pm.add_parameter("power", initial_value=-10, unit="dBm") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_instance("qubit", "q0") + pm.add_instance("qubit", "q1") + pm.update() + + # one shared IF for both qubits: the Type Lock's Globals Target + pm.lock_type_parameter("qubit", "IF") + + # the sweep: set the Target, read the Followers (the page shows + # this loop with print, so the script runs the same expressions) + printed = [] + for if_hz in (10e6, 11e6, 12e6): + cli.call("parameter_manager.set", "_globals.qubit.IF", if_hz) + printed.append(f"{pm.q0.IF()} {pm.q1.IF()}") + assert printed == [ + "10000000.0 10000000.0", + "11000000.0 11000000.0", + "12000000.0 12000000.0", + ], printed + + # a locked Follower refuses set: unlock first, then rejoin + try: + pm.q0.IF.set(13e6) + deviate_error = None + except Exception as exc: # noqa: BLE001 + deviate_error = str(exc) + assert deviate_error is not None + assert "is locked to" in deviate_error, deviate_error + pm.unlock("q0.IF") + pm.q0.IF.set(13e6) + assert pm.q0.IF() == 13000000.0 + assert pm.q1.IF() == 12000000.0 + pm.relock("q0.IF") + assert pm.q0.IF() == 12000000.0 + + # end of the experiment: save the profile + pm.toFile() + assert (Path.cwd() / "parameter_manager-parameter_manager.json").exists() + print("section_using_it_from_measurement_code: OK") + + +if __name__ == "__main__": + section_concept() + section_hierarchical_parameters() + section_types() + section_locks() + section_type_locks_and_globals() + section_profiles_and_files() + section_the_gui() + section_using_it_from_measurement_code() + print("verify_parameter_manager: all sections OK") From d1dcd4251b4dffaf94cbb992a7d8f56cd3b1e297 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 19:02:08 -0500 Subject: [PATCH 087/107] 6.1: fix from review round 1: Globals access mechanism, Server-window claim, GUI wording, profiles setup and legacy flow, script coverage --- TEST_AUDIT.md | 2 +- docs/user_guide/parameter_manager.md | 124 +++++--- .../user_guide/verify_parameter_manager.py | 266 ++++++++++++++---- 3 files changed, 294 insertions(+), 98 deletions(-) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 88da3d1..7b7512e 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -45,7 +45,7 @@ States: | gui_features.md (future) | Parameter Manager GUI — live creation from another client | `ModelParameters.updateParameter`'s `parameter-creation` branch calls `instrument.update()` and then `nestedAttributeFromString` on the Proxy Instrument; a parameter another Client creates while the GUI is open raises `AttributeError` there (stale Proxy blueprint), so the row never appears | Found during the plan 5.1 work (coder probe, verified pre-existing by all six reviewers) | fixed | Fixed in plan task 5.5 by Marcos's decision (rule 6 exception): the branch resolves the element first and, on `AttributeError`, refreshes the stale Proxy blueprint and resolves again; regression tests live in `test/pytest/test_pm_gui.py` | | user_guide/parameter_manager.md (future) | Profiles — GUI start with no profile file | `ParameterManagerGui.__init__` calls `loadProfile`, which calls `switch_to_profile` with the combo's current text; with no profile file present `switch_to_profile` raises, so the GUI cannot be built until one profile exists | Found during the plan 5.1 work (coder probe) | gap | Pre-existing; not changed per plan rule 6 | | gui_features.md (future) | Parameter Manager GUI — `parameter-update` for a row with no widget | `ParameterManagerTreeView.onItemNewValue` indexes `self.delegate.parameters[itemName]` without a guard, so a `parameter-update` Broadcast for a row whose editor widget was never created raises `KeyError` and the value never shows | Found during the plan 5.3 round-0 review (reviewer-qwen) | gap | Pre-existing; not changed per plan rule 6; the new `ParameterManagerGui._on_item_new_value` guards with `.get` and logs instead of raising | -| user_guide/parameter_manager.md | Type Locks and Globals | From a Client, attribute access on the Proxy cannot reach the Globals submodule (QCoDeS reserves underscore names), and the dotted `set`/`get` through `Client.call("parameter_manager.set", ...)` is the working route to a Globals parameter | `section_type_locks_and_globals` in `verify_parameter_manager.py` | covered | The `Client.call` route is pinned over the wire in `test_pm_types.py` (the Globals-Target proxy tests, whose comments note the same shadowing); the Proxy's `AttributeError` on `_globals` itself has no direct pytest | +| user_guide/parameter_manager.md | Type Locks and Globals | On a Proxy Instrument, `pm.get`/`pm.set` resolve to QCoDeS' local deprecated `InstrumentBase.get`/`set` and raise `KeyError` on a dotted path, so the working route to a Globals parameter is `Client.call("parameter_manager.set", ...)`; attribute access reaches the Globals submodule on a fresh Proxy or after `update()`, while a Proxy built before the parameter existed raises `AttributeError` until then | `section_type_locks_and_globals` in `verify_parameter_manager.py` | covered | The `Client.call` route is pinned over the wire in `test_pm_types.py` (the Globals-Target proxy tests, whose comments note the shadowing); the `AttributeError`-until-`update()` behaviour has no direct pytest | ## Manual checks diff --git a/docs/user_guide/parameter_manager.md b/docs/user_guide/parameter_manager.md index ed388b1..721c8d7 100644 --- a/docs/user_guide/parameter_manager.md +++ b/docs/user_guide/parameter_manager.md @@ -422,10 +422,12 @@ Setting the Globals parameter moves every Follower at once: ``` :::{note} -Names starting with an underscore are reserved by QCoDeS instruments, so -attribute access on the Proxy cannot reach the Globals submodule. The -Parameter Manager's own dotted `get` and `set` methods reach it; call them -through `Client.call`, as above. +Why `Client.call`? On a Proxy Instrument, `pm.get` and `pm.set` are QCoDeS' +own local shorthands, and they only take a plain parameter name: a dotted +path raises `KeyError`. Attribute access does reach the Globals submodule +once the Proxy knows it; a Proxy built before the Globals parameter existed +needs one `pm.update()` first. The Parameter Manager's own dotted `get` and +`set` run through `Client.call`, as above. ::: An explicit Target names any parameter of the same Parameter Manager @@ -439,10 +441,11 @@ instead: ``` The return value names the Instance parameters that were skipped, and the -Server logs a warning: `q01.IF` already carried a Lock on another Target -(the Globals parameter), and a Lock is never re-pointed behind its holder's -back. Everything else is locked to the new Target. Declaring the same Type -Lock again is how the GUI's lock all button re-applies it to everyone. +Parameter Manager logs a warning: `q01.IF` already carried a Lock on another +Target (the Globals parameter), and a Lock is never re-pointed behind its +holder's back. Everything else is locked to the new Target. Declaring the +same Type Lock again is how the GUI's lock all button re-applies it to +everyone. `unlock_type_parameter` removes only the rule. The Locks it created stay until they are removed individually, and Instances created after the removal @@ -469,9 +472,13 @@ and is saved with the profile. A Parameter Manager saves itself as a JSON profile document in the working directory of the Server process, so the state survives restarts and can be -switched per experiment: +switched per experiment. This section starts from two parameters and a Lock +between them, so the document below shows all three shapes: ```pycon +>>> pm.add_parameter("lo.frequency", initial_value=5e9, unit="Hz") +>>> pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") +>>> pm.lock("q01.IF", "lo.frequency") >>> pm.toFile() ``` @@ -521,13 +528,22 @@ Three things to read off that document: PMLockBluePrint(target='parameter_manager.lo.frequency', locked=True, _class_type='PMLockBluePrint') ``` -:::{note} Loading removes parameters the document does not list; that is the -`deleteMissing` default of the reader underneath. `fromFile` always loads -with that default today, and the dictionary variant takes it explicitly: -`pm.fromParamDict(pm.toParamDict(), deleteMissing=False)` keeps parameters -the document does not list. -::: +`deleteMissing` default of the reader underneath, and `fromFile` always +loads with that default today. The dictionary variant takes it explicitly, +so a document that omits one parameter shows the difference: + +```pycon +>>> pm.add_parameter("temp.extra", initial_value=1) +>>> document = pm.toParamDict() +>>> del document["parameters"]["parameter_manager.temp.extra"] +>>> pm.fromParamDict(document, deleteMissing=False) +>>> pm.has_param("temp.extra") +True +>>> pm.fromParamDict(document) +>>> pm.has_param("temp.extra") +False +``` Any file named `parameter_manager-.json` in the working directory is a profile. `refresh_profiles` re-reads the directory, `list_profiles` @@ -537,7 +553,7 @@ the profile you name: ```pycon >>> pm.toFile(name="cooldown") ->>> pm.refresh_profiles() +>>> sorted(pm.refresh_profiles()) ['parameter_manager-cooldown.json', 'parameter_manager-parameter_manager.json'] >>> pm.unlock("q01.IF") >>> pm.lo.frequency.set(8e9) @@ -553,11 +569,35 @@ True PMLockBluePrint(target='parameter_manager.lo.frequency', locked=False, _class_type='PMLockBluePrint') ``` +A file without a top-level `version` key is the old flat map from before +Types and Locks existed. Write one (any file named +`parameter_manager-.json` works) and load it: + +```json +{ + "parameter_manager.old_target": {"unit": "Hz", "value": 5}, + "parameter_manager.old_param": {"unit": "M", "value": 123} +} +``` + +```pycon +>>> pm.add_parameter("old_target", initial_value=5, unit="Hz") +>>> pm.add_parameter("old_param", initial_value=1, unit="M") +>>> pm.lock("old_param", "old_target") +>>> pm.unlock("old_param") # present but unlocked, so the load can set its value +>>> pm.fromFile("parameter_manager-legacy.json") +>>> pm.old_param() +123 +>>> pm.list_locks() +{'old_param': PMLockBluePrint(target='parameter_manager.old_target', locked=False, _class_type='PMLockBluePrint')} +``` + :::{note} -A profile file without a top-level `version` key is the old flat map from -before Types and Locks existed. It loads as parameters only, and the Types -and Locks of the running Parameter Manager are left untouched. Saving always -writes the version-2 document, so one save over an old file upgrades it. +The legacy reader writes no Types and no Locks. Parameters it does not list +are removed as above, their Locks with them; a Lock whose parameters stay is +untouched, exactly as the load above leaves the unlocked `old_param` Lock. +Saving always writes the version-2 document, so one save over an old file +upgrades it. ::: ## The GUI @@ -571,11 +611,16 @@ instrumentserver-param-manager --port 5555 The launcher connects a Client to the Server on that port, creates the Parameter Manager named `parameter_manager` if it does not exist yet, and -opens the window. `--name` chooses a different Parameter Manager. The same -widget is embedded in the Server window, which shows it for a Parameter -Manager in its Station; [the Server](server.md) covers launching, and -[GUI features](gui_features.md) describes the patterns shared by every -instrument window: starring, trashing, filtering, and the detachable tabs. +opens the window. `--name` chooses a different Parameter Manager. The Server +window itself opens the generic instrument widget for a Parameter Manager +unless the station config's `gui` entry names +`instrumentserver.gui.instruments.ParameterManagerGui`, as the +`serverConfig.yml` in the repository does; then the Server window embeds the +same widget, and the launcher above is the sure way to get it. +[the Server](server.md) covers launching and the station config, and +[GUI features](gui_features.md) describes the `gui` entry and the patterns +shared by every instrument window: starring, trashing, filtering, and the +detachable tabs. ### The Parameters tab @@ -585,9 +630,9 @@ specific to the Parameter Manager: - **Tints and gutter bands.** Every parameter of an Instance is tinted with the colour of its Claiming Type, and the thin coloured band at the left - edge stacks one segment per Type covering the row, innermost first. When a - Client edits a Type, the tints follow live, because the Parameter Manager - emits a `pm-type-update` Broadcast. + edge stacks one segment per Type covering the row, outermost first, up to + three. When a Client edits a Type, the tints follow live, because the + Parameter Manager emits a `pm-type-update` Broadcast. :::{admonition} 📸 SCREENSHOT NEEDED :class: attention @@ -619,10 +664,11 @@ theme: `docs/_static/user_guide/parameter_manager/tree_tints_dark.png`. beside the usual actions. "Lock to…" arms the Target picker: a strip appears under the toolbar, naming -the Follower, with a line edit that completes over every parameter path -(ranked so that paths in the Follower's own submodule come first) and a -Cancel button. Click a tree row or complete a path to pick the Target. If -the Server refuses, for a cycle for example, the strip shows the error. +the Follower, with a line edit that completes over every parameter path, +ranked so that the same relative path on another Instance comes first, then +paths containing that relative path, then the rest, and a Cancel button. +Click a tree row or complete a path to pick the Target. If the Server +refuses, for a cycle for example, the strip shows the error. :::{admonition} 📸 SCREENSHOT NEEDED :class: attention @@ -684,18 +730,18 @@ theme: `docs/_static/user_guide/parameter_manager/locks_panel_dark.png`. ### The Types tab -The Types tab, or Ctrl+Shift+Y, keeps the structure work in one place, in -three panes: +The Types tab (Ctrl+Shift+Y switches between the two tabs) keeps the +structure work in one place, in three panes: - Left: the list of Types, each with its number of Instances and parameters, tinted in the Type's colour, and the "New type:" strip below. - Top right: the selected Type's entries as a tree. An entry of the Type itself has an editable default, a Remove button, and the Type Lock toggle - in the "locked to" column, which doubles as the re-target button while the - entry is locked. Entries that come from a Nested Type are read-only and - say "defined by "; submodule rows show which Type they require and - can drop the requirement. The "Add to type" and "Nested type" strips below - add entries and Nested Type requirements. + in the "locked to" column; while the entry is locked, the column also + shows a re-target button and the Target's path. Entries that come from a + Nested Type are read-only and say "defined by "; submodule rows show + which Type they require and can drop the requirement. The "Add to type" + and "Nested type" strips below add entries and Nested Type requirements. - Bottom right: the Instances of the selected Type, each with its parameter count and the other Types it also carries. "Show" jumps to the Parameters tab and selects the Instance; the "New instance:" strip adds one. diff --git a/test/docs_verification/user_guide/verify_parameter_manager.py b/test/docs_verification/user_guide/verify_parameter_manager.py index 41f970f..a84c202 100644 --- a/test/docs_verification/user_guide/verify_parameter_manager.py +++ b/test/docs_verification/user_guide/verify_parameter_manager.py @@ -13,9 +13,11 @@ button, the context menu, the arm strip flow, the Locks panel, the Types tab panes, and the delete-Target confirmation dialog) cannot be asserted from a script. They are verified manually (the plan's task 5.6 records the -end-to-end GUI check) and captured in the page's screenshots. What the GUI -section states about keyboard shortcuts is asserted here against the GUI's -shortcut registry. +end-to-end GUI check) and captured in the page's screenshots; the same goes +for the Server window showing the generic instrument widget unless the +station config's ``gui`` entry names the Parameter Manager widget. What the +GUI section states about keyboard shortcuts is asserted here against the +GUI's shortcut registry. """ import json @@ -24,6 +26,7 @@ import shutil import sys import tempfile +import time from contextlib import contextmanager from pathlib import Path @@ -55,6 +58,19 @@ def workspace(): shutil.rmtree(path, ignore_errors=True) +def capture_one_broadcast(action): + """Run ``action`` under a Broadcast capture and return the messages. + + Sleeps briefly after the first message arrived, so a caller counting + the messages sees the ones belonging to this action only. + """ + with capture_broadcasts([PM_NAME]) as cap: + action() + messages = cap.wait_for(1) + time.sleep(0.2) + return messages + + # --------------------------------------------------------------------------- # Section: Concept # @@ -93,10 +109,9 @@ def section_concept() -> None: # # Page claims: dotted paths address parameters in nested Parameter Groups, # which are created on demand; add_parameter / remove_parameter / list / -# has_param manage the tree; get and set work through dotted paths and -# through Proxy attribute access; removing a parameter leaves emptied -# Parameter Groups in place only with cleanup=False, and -# remove_empty_submodules prunes them. +# has_param manage the tree; get and set work through Proxy attribute +# access; the default remove_parameter prunes the Parameter Group it +# empties; cleanup=False keeps it, and remove_empty_submodules prunes it. # --------------------------------------------------------------------------- def section_hierarchical_parameters() -> None: with workspace(), server(): @@ -119,23 +134,34 @@ def section_hierarchical_parameters() -> None: assert pm.q01.readout.IF() == 21000000.0 assert pm.q01.readout.IF.unit == "Hz" - # cleanup=False leaves the emptied Parameter Group in the tree + # the default removes the parameter and prunes the Parameter + # Group it empties + pm.remove_parameter("q01.readout.IF") + assert pm.list() == ["q01.power"] + try: + pm.q01.readout + pruned = False + except AttributeError: + pruned = True + assert pruned + + # cleanup=False keeps the emptied Parameter Group instead ... + pm.add_parameter("q01.readout.IF", initial_value=1, unit="Hz") pm.remove_parameter("q01.readout.IF", cleanup=False) pm.update() assert not pm.has_param("q01.readout.IF") assert pm.q01.readout is not None # ... and remove_empty_submodules prunes every empty group - pm.remove_parameter("q01.power", cleanup=False) pm.remove_empty_submodules() pm.update() - assert pm.list() == [] try: - pm.q01 - gone = False + pm.q01.readout + pruned = False except AttributeError: - gone = True - assert gone + pruned = True + assert pruned + assert pm.list() == ["q01.power"] print("section_hierarchical_parameters: OK") @@ -144,12 +170,15 @@ def section_hierarchical_parameters() -> None: # # Page claims: a Type is a named shape (relative paths with defaults and # units, plus Nested Types); Instances are duck-typed, recomputed on demand, -# never stored; add_type_parameter writes the entry into every Instance -# lacking it; set_type_parameter_default only affects Instances created -# later; set_type_parameter_unit propagates to every Instance; Nested Types -# expand under their submodule; add_instance writes the shape into a new -# Parameter Group; instances_of / types_of answer on demand; removing an -# entry, a Nested Type or a Type leaves the parameters alone. +# never stored; add_instance writes the shape into a new Parameter Group and +# keeps parameters that exist at a target path already; a wrong-unit +# submodule is no Instance; add_type_parameter writes the entry into every +# Instance lacking it; a Type edit emits one pm-type-update Broadcast; +# set_type_parameter_default only affects Instances created later; +# set_type_parameter_unit propagates to every Instance; Nested Types expand +# under their submodule; instances_of / types_of answer on demand; removing +# an entry, a Nested Type or a Type leaves the parameters alone; a Type +# still nested in another refuses to be removed, naming the nester. # --------------------------------------------------------------------------- def section_types() -> None: with workspace(), server(): @@ -178,14 +207,27 @@ def section_types() -> None: assert pm.instances_of("qubit") == ["q01", "q02", "q03"] assert pm.types_of("q03.IF") == ["qubit"] + # the unit is part of the shape: a submodule with the wrong unit + # is no Instance + pm.add_parameter("q05.IF", initial_value=1, unit="V") + pm.add_parameter("q05.octave_gain", initial_value=1, unit="dB") + assert "q05" not in pm.instances_of("qubit") + # a new entry is written into every Instance lacking it pm.add_type_parameter("qubit", "window", default=0.5, unit="s") pm.update() assert pm.q01.window() == 0.5 assert pm.q03.window() == 0.5 + # a Type edit announces itself: one pm-type-update Broadcast + messages = capture_one_broadcast( + lambda: pm.set_type_parameter_default("qubit", "window", 1.0) + ) + text = repr(messages) + assert text.count("pm-type-update") == 1, text + assert "parameter_manager.qubit" in text, text + # a new default only affects Instances created later - pm.set_type_parameter_default("qubit", "window", 1.0) assert pm.q01.window() == 0.5 pm.add_instance("qubit", "q04") pm.update() @@ -219,17 +261,27 @@ def section_types() -> None: "q04.readout", ] - # duck-typing cuts both ways: deleting a required parameter makes - # the submodule stop matching, and nothing else changes - pm.remove_parameter("q02.IF") - assert pm.has_param("q02.octave_gain") - assert pm.instances_of("qubit") == ["q01", "q03", "q04"] + # a Type still required as a Nested Type refuses to be removed, + # naming the Types that nest it + try: + pm.remove_type("readout") + nested_error = None + except Exception as exc: # noqa: BLE001 + nested_error = str(exc) + assert nested_error is not None + assert "qubit" in nested_error, nested_error # removing an entry from the Type leaves the parameters alone, # and the Instances keep matching: the required shape only shrank pm.remove_type_parameter("qubit", "window") assert pm.has_param("q01.window") assert pm.has_param("q04.window") + assert pm.instances_of("qubit") == ["q01", "q02", "q03", "q04"] + + # duck-typing cuts both ways: deleting a required parameter makes + # the submodule stop matching, and nothing else changes + pm.remove_parameter("q02.IF") + assert pm.has_param("q02.octave_gain") assert pm.instances_of("qubit") == ["q01", "q03", "q04"] # same for a Nested Type and for the Type itself: the parameters @@ -241,6 +293,17 @@ def section_types() -> None: assert pm.list_types() == ["readout"] assert pm.has_param("q01.IF") assert pm.q01.IF() == 10000000.0 + + # add_instance keeps parameters that exist at a target path + # already, with their own value and unit + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_parameter("q09.IF", initial_value=99, unit="Hz") + pm.add_instance("qubit", "q09") + pm.update() + assert pm.q09.IF() == 99 + assert pm.q09.IF.unit == "Hz" + assert pm.instances_of("qubit") == ["q09"] print("section_types: OK") @@ -252,10 +315,11 @@ def section_types() -> None: # answers get with the Target's value and refuses set with an error naming # the Target; values are pulled on get, so setting the Target is enough and # unlocking exposes the Follower's own value again; Locks chain and each hop -# reads by its own state; cycles are refused with an error listing the -# chain; get_lock / list_locks / followers_of report the state; deleting a -# Target removes the Locks that pointed at it; the Parameter Manager emits a -# pm-lock-update Broadcast for each affected Follower. +# reads by its own state; cycles and self-locks are refused with an error +# listing the chain; the Target lives in the same Parameter Manager (D8); +# get_lock / list_locks / followers_of report the state; deleting a Target +# removes the Locks that pointed at it; every Lock method the page names +# emits one pm-lock-update Broadcast per affected Follower. # --------------------------------------------------------------------------- def section_locks() -> None: with workspace(), server(): @@ -300,9 +364,15 @@ def section_locks() -> None: assert pm.q01.IF() == 5000000.0 pm.relock("q01.IF") assert pm.q01.IF() == 11000000.0 - pm.toggle_lock("q01.IF") + + # every Lock method the page names announces the Follower: one + # pm-lock-update per state change + messages = capture_one_broadcast(lambda: pm.toggle_lock("q01.IF")) + text = repr(messages) + assert text.count("pm-lock-update") == 1, text + assert "parameter_manager.q01.IF" in text, text assert pm.get_lock("q01.IF").locked is False - pm.toggle_lock("q01.IF") + messages = capture_one_broadcast(lambda: pm.toggle_lock("q01.IF")) assert pm.get_lock("q01.IF").locked is True # bookkeeping: followers_of and list_locks @@ -345,15 +415,30 @@ def section_locks() -> None: assert self_error is not None assert "cannot lock" in self_error, self_error + # the Target must live in the same Parameter Manager (D8) + other = cli.find_or_create_instrument("parameter_manager_2", PM_CLASS) + other.add_parameter("elsewhere.x", initial_value=1) + try: + pm.lock("q01.IF", "parameter_manager_2.elsewhere.x") + cross_error = None + except Exception as exc: # noqa: BLE001 + cross_error = str(exc) + assert cross_error is not None + assert "does not exist" in cross_error, cross_error + + # removing a Lock announces it with a None payload + messages = capture_one_broadcast(lambda: pm.remove_lock("q02.IF")) + text = repr(messages) + assert text.count("pm-lock-update") == 1, text + assert "parameter_manager.q02.IF" in text, text + # deleting a Target removes the Locks that pointed at it; the # Followers become plain parameters pm.remove_parameter("q01Data.IF") assert pm.get_lock("q01.IF") is None assert pm.q01.IF() == 5000000.0 - # q02.IF's Lock pointed at q01.IF, which still exists - assert pm.get_lock("q02.IF") is not None - assert pm.q02.IF() == 5000000.0 - pm.remove_lock("q02.IF") + # q02.IF's Lock pointed at q01.IF, which still exists ... but it + # was removed above, so the tree holds no Lock here assert pm.list_locks() == {} print("section_locks: OK") @@ -365,11 +450,16 @@ def section_locks() -> None: # parameter in every current Instance; with no explicit Target the Target is # the Globals parameter _globals.., created on demand with the # entry's default and unit; setting the Globals parameter moves every -# Follower; Instance parameters already locked to another Target are +# Follower; on a Proxy, pm.get/pm.set are QCoDeS' local shorthands and raise +# KeyError on a dotted path, attribute access reaches the Globals submodule +# once the Proxy knows it (one update() for a Proxy built before it +# existed), and the Parameter Manager's own dotted get/set run through +# Client.call; Instance parameters already locked to another Target are # skipped, returned and logged; unlock_type_parameter removes only the rule, # the Locks it created stay; new Instances created after the rule is removed -# get no Lock; Globals is never an Instance, and parameters under it cannot -# be created through add_parameter. +# get no Lock; Globals is never an Instance, its parameters cannot be +# created through add_parameter, and a Globals parameter is saved with the +# profile. # --------------------------------------------------------------------------- def section_type_locks_and_globals() -> None: with workspace(), server(): @@ -385,9 +475,31 @@ def section_type_locks_and_globals() -> None: assert pm.lock_type_parameter("qubit", "IF") == [] assert pm.has_param("_globals.qubit.IF") assert "_globals.qubit.IF" in pm.list() - # names starting with an underscore are reserved by QCoDeS, so - # attribute access cannot reach Globals; the Parameter Manager's - # own dotted get and set do, through Client.call + + # this Proxy was built before the Globals parameter existed, so + # its cached Blueprint does not know the submodule yet + try: + pm._globals + stale_error = None + except AttributeError as exc: # noqa: BLE001 + stale_error = str(exc) + assert stale_error is not None + + # one update() later, attribute access reaches it + pm.update() + assert pm._globals.qubit.IF() == 10000000.0 + + # the Proxy's own dotted get and set are QCoDeS' local, + # deprecated shorthands: they raise KeyError on a dotted path + try: + pm.get("_globals.qubit.IF") + proxy_get_error = None + except KeyError as exc: # noqa: BLE001 + proxy_get_error = str(exc) + assert proxy_get_error is not None + + # the Parameter Manager's own dotted get and set are reachable + # through Client.call assert cli.call("parameter_manager.get", "_globals.qubit.IF") == 10000000.0 for instance in ("q01", "q02"): lock = pm.get_lock(f"{instance}.IF") @@ -398,6 +510,7 @@ def section_type_locks_and_globals() -> None: # setting the Globals parameter moves every Follower cli.call("parameter_manager.set", "_globals.qubit.IF", 12e6) + assert pm._globals.qubit.IF() == 12000000.0 assert pm.q01.IF() == 12000000.0 assert pm.q02.IF() == 12000000.0 @@ -439,6 +552,13 @@ def section_type_locks_and_globals() -> None: globals_error = str(exc) assert globals_error is not None assert "reserved" in globals_error, globals_error + + # a Globals parameter is saved with the profile + pm.toFile() + document = json.loads( + (Path.cwd() / "parameter_manager-parameter_manager.json").read_text() + ) + assert "parameter_manager._globals.qubit.IF" in document["parameters"] print("section_type_locks_and_globals: OK") @@ -447,11 +567,14 @@ def section_type_locks_and_globals() -> None: # # Page claims: toFile writes the version-2 profile document (values are the # parameters' own values, lock appears only on Followers); fromFile loads it -# again, with deleteMissing removing parameters the file does not list; -# switch_to_profile saves the current profile, then clears everything, then -# loads the new one; list_profiles / refresh_profiles report the profile -# files of the working directory; a file without a version key is the legacy -# flat map and loads as parameters only; saving always writes version 2. +# again, with deleteMissing removing parameters the document does not list; +# the dictionary variant takes deleteMissing explicitly; switch_to_profile +# saves the current profile, then clears everything, then loads the new one; +# sorted(pm.refresh_profiles()) reports the profile files of the working +# directory; a file without a version key is the legacy flat map: it loads +# as parameters only, removes the parameters it does not list (their Locks +# with them), leaves Locks whose parameters stay untouched, and writes no +# Types and no Locks; saving always writes version 2. # --------------------------------------------------------------------------- def section_profiles_and_files() -> None: with workspace(), server(): @@ -491,20 +614,24 @@ def section_profiles_and_files() -> None: target="parameter_manager.lo.frequency", locked=True ) - # deleteMissing controls whether parameters the document does - # not list are removed. fromFile always loads with the default - # (True); the dictionary variant takes it explicitly. + # deleteMissing removes the parameters the document does not + # list; deleteMissing=False keeps them pm.add_parameter("temp.extra", initial_value=1) - pm.fromParamDict(pm.toParamDict(), deleteMissing=False) + document = pm.toParamDict() + del document["parameters"]["parameter_manager.temp.extra"] + pm.fromParamDict(document, deleteMissing=False) assert pm.has_param("temp.extra") - pm.fromFile() + pm.fromParamDict(document) assert not pm.has_param("temp.extra") # a second profile: saving to a profile file also selects it; # a plain name lands in the working directory as # parameter_manager-cooldown.json pm.toFile(name="cooldown") - pm.refresh_profiles() + assert sorted(pm.refresh_profiles()) == [ + "parameter_manager-cooldown.json", + "parameter_manager-parameter_manager.json", + ] assert sorted(pm.list_profiles()) == [ "parameter_manager-cooldown.json", "parameter_manager-parameter_manager.json", @@ -531,15 +658,36 @@ def section_profiles_and_files() -> None: ) # a file without a version key is the legacy flat map: it loads - # as parameters only + # as parameters only. Two of its parameters carry an unlocked + # Lock in-session first, to observe what the load does to Locks + # (a locked Follower would refuse the load's set, D6) + pm.add_parameter("old_target", initial_value=5, unit="Hz") + pm.add_parameter("old_param", initial_value=1, unit="M") + pm.lock("old_param", "old_target") + pm.unlock("old_param") legacy = Path.cwd() / "parameter_manager-legacy.json" legacy.write_text( - json.dumps({"parameter_manager.old_param": {"value": 123, "unit": "M"}}) + json.dumps( + { + "parameter_manager.old_target": {"unit": "Hz", "value": 5}, + "parameter_manager.old_param": {"unit": "M", "value": 123}, + } + ) ) - pm.fromFile(str(legacy)) + pm.fromFile("parameter_manager-legacy.json") + # the parameters the file does not list are removed, their Locks + # with them; the Lock between the two listed parameters stays, + # untouched by the reader assert not pm.has_param("lo.frequency") + assert not pm.has_param("q01.IF") + assert pm.list_locks() == { + "old_param": PMLockBluePrint( + target="parameter_manager.old_target", locked=False + ) + } pm.update() assert pm.old_param() == 123 + assert pm.old_target() == 5 # saving always writes version 2, even over a legacy file; and # loading a profile file selected it, so the save lands there @@ -556,8 +704,10 @@ def section_profiles_and_files() -> None: # the arm strip, the Locks panel, the Types tab panes, and the delete-Target # confirmation) cannot be asserted from a script; it is verified manually # (plan task 5.6 records the end-to-end GUI check) and captured in the -# page's screenshots. The keyboard shortcuts the page lists come from the -# GUI's shortcut registry, and that is asserted here. +# page's screenshots. The same goes for the Server window showing the +# generic instrument widget unless the station config's gui entry names the +# Parameter Manager widget. The keyboard shortcuts the page lists come from +# the GUI's shortcut registry, and that is asserted here. # --------------------------------------------------------------------------- def section_the_gui() -> None: from instrumentserver.gui.shortcuts import KeyboardShortcutManager From b8a99d2c04dcb756e4efba020306550346862c6a Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 19:25:26 -0500 Subject: [PATCH 088/107] 6.1: fix from review round 2: restore the survivor assertions after the Target deletion and make capture_one_broadcast return the post-settle messages --- .../user_guide/verify_parameter_manager.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/test/docs_verification/user_guide/verify_parameter_manager.py b/test/docs_verification/user_guide/verify_parameter_manager.py index a84c202..31b7c6d 100644 --- a/test/docs_verification/user_guide/verify_parameter_manager.py +++ b/test/docs_verification/user_guide/verify_parameter_manager.py @@ -59,16 +59,18 @@ def workspace(): def capture_one_broadcast(action): - """Run ``action`` under a Broadcast capture and return the messages. + """Run ``action`` under a Broadcast capture and return every message + the capture saw while ``action`` ran, plus a short settle wait. - Sleeps briefly after the first message arrived, so a caller counting - the messages sees the ones belonging to this action only. + ``wait_for(1)`` returns as soon as the first message lands, so the + settle wait runs before the list is taken: a caller counting the + messages sees the ones belonging to this action only. """ with capture_broadcasts([PM_NAME]) as cap: action() - messages = cap.wait_for(1) + cap.wait_for(1) time.sleep(0.2) - return messages + return list(cap.messages) # --------------------------------------------------------------------------- @@ -426,19 +428,22 @@ def section_locks() -> None: assert cross_error is not None assert "does not exist" in cross_error, cross_error - # removing a Lock announces it with a None payload - messages = capture_one_broadcast(lambda: pm.remove_lock("q02.IF")) - text = repr(messages) - assert text.count("pm-lock-update") == 1, text - assert "parameter_manager.q02.IF" in text, text - # deleting a Target removes the Locks that pointed at it; the # Followers become plain parameters pm.remove_parameter("q01Data.IF") assert pm.get_lock("q01.IF") is None assert pm.q01.IF() == 5000000.0 - # q02.IF's Lock pointed at q01.IF, which still exists ... but it - # was removed above, so the tree holds no Lock here + # a Follower of a survivor keeps its Lock: q02.IF is locked to + # q01.IF, which still exists + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager.q01.IF", locked=True + ) + + # removing a Lock announces it with a None payload + messages = capture_one_broadcast(lambda: pm.remove_lock("q02.IF")) + text = repr(messages) + assert text.count("pm-lock-update") == 1, text + assert "parameter_manager.q02.IF" in text, text assert pm.list_locks() == {} print("section_locks: OK") From dccb468aa00666a8ba2d23b6e7cf1edb779374fd Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 19:54:03 -0500 Subject: [PATCH 089/107] 6.1: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 47 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 0533759..5f13d8d 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -985,3 +985,50 @@ The "Types" tab (`self.typesTab`) now holds a `TypesPane` (`self.typesPane`). It ### Process notes - The coder's first named-test run hung, because a modal dialog blocked pytest before the `QTimer`-driven canceler was in place. The coder profiled its own pytest with `sample` and killed it by pid. - The orchestrator deleted run logs the coder had left behind. + +## 6.1 User Guide: `docs/user_guide/parameter_manager.md` — 2026-09-28 + +The stub `docs/user_guide/parameter_manager.md` is now the full User Guide page. It has eight level-2 sections: "Concept", "Hierarchical parameters", "Types", "Locks", "Type Locks and Globals", "Profiles and files", "The GUI" and "Using it from measurement code". The GUI section has five screenshot placeholders and a keyboard shortcuts table. The verification script `test/docs_verification/user_guide/verify_parameter_manager.py` has one `section_*` function per page section and uses only the shared helpers. It runs in a throwaway `verify_pm_*` directory, so no profile file is left behind. The task added three docstring rows and one tests row to `TEST_AUDIT.md`, and changed no source code. This task opens Phase 6. + +### Commit by commit +- `da417a9` The page, the script and the `TEST_AUDIT.md` rows. The orchestrator's coder spec set seven readings. The main ones: + - Reading 1 defined "following the docs protocol" (plan rule 9). The script exercises every claim before it goes on the page. The site builds with no new warnings. The style rules of `PLAN_docs_refactor.md` apply. Screenshots are placeholder admonitions with light/dark paths under `docs/_static/user_guide/parameter_manager/`. The six reviewers stand in for the docs plan's GRILL/REVISE loop with Marcos, and the coder commits (the standing exception on this worktree). The orchestrator flagged this reading for Marcos in `decisions.md`. + - Reading 2 names the script `verify_parameter_manager.py`, following the docs convention `verify_.py`. The plan text's `parameter_manager.py` is treated as a slip, and this is recorded in `decisions.md`. + - Reading 3 fixed the section order and what each section covers. Reading 5 sent docstring problems to `TEST_AUDIT.md` rows instead of edits. The rows added are `ParameterGroup.has_param` (no docstring), `ParameterGroup.get`/`set` (no docstrings, and on a Proxy Instrument they resolve to QCoDeS' local `InstrumentBase.get`/`set`), and `ParameterManager.fromFile` (it documents `deleteMissing` but never forwards it, and it names a file the code never uses). A tests-table row was added for "Type Locks and Globals". + + At the start, `sphinx-build` was missing from the uv environment. `uv run --group docs` (the same as CI's docs group) fixed that. "Zero Sphinx warnings" was read as "none from this page": the build still shows 3 ADR toctree warnings that were there before, and they are left for 6.3. Orchestrator run: ruff clean, script 8/8 sections OK, docs build with only the 3 old warnings, 543 in the full suite. +- `d1dcd42` Fix from round 0, eleven items. Every reviewer asked for changes: + - The Globals note was wrong, and so was the tests row that repeated it. They said QCoDeS reserves underscore names, so attribute access cannot reach `_globals`. The orchestrator's probe showed that attribute access does work, on a fresh Proxy or after `pm.update()`. Only a Proxy built before the Globals parameter existed raises `AttributeError`, because its Blueprint is cached. What is really unavailable is the dotted `pm.get`/`pm.set`: on the Proxy these are QCoDeS' local shorthands, and a dotted path raises `KeyError`. That is why the page uses `Client.call`. The note, the `TEST_AUDIT.md` row and the script's four assertions now say this. Raised by reviewer-glm, reviewer-qwen, test-reviewer-qwen and plan-checker-qwen (must-fix). test-reviewer-glm's request to assert the `AttributeError` was reworded to the stale-Proxy case. + - The page claimed the Server window shows the Parameter Manager widget. It opens the generic instrument widget unless the station config's `gui` entry names `instrumentserver.gui.instruments.ParameterManagerGui`, as `serverConfig.yml` does. The page now says so, and presents the launcher as the sure way to get the widget. Raised by plan-checker-glm, reviewer-glm, test-reviewer-qwen and plan-checker-qwen. + - Three GUI wordings were fixed to match what shipped in 5.3–5.5: + - The gutter bands go outermost first, up to three. + - The arm strip lists the same relative path on another Instance first, then paths containing it, then the rest. + - On a locked Type entry, the re-target button and the Target path sit beside the Type Lock toggle. The page had said the toggle doubles as the re-target button. + + Each was raised by three or four of the general reviewers and plan checkers. + - "Profiles and files" now starts with the `add_parameter`/`lock` block the script runs, so you can reproduce the shown document from an empty Parameter Manager (test-reviewer-qwen, reviewer-glm, plan-checker-qwen). `refresh_profiles()` is shown as `sorted(...)`, because the API returns the files in directory order (both test reviewers and reviewer-qwen). The `deleteMissing=False` example did nothing, since it loaded a document that omitted nothing. It became a document with `temp.extra` dropped, loaded both ways (plan-checker-qwen, a nit sent because the docs plan asks for concrete examples). + - Legacy note. reviewer-glm and test-reviewer-glm disagreed about whether a Lock survives the legacy load. The fix list had the script observe what happens and the prose match it. The page now shows a legacy flat file loading, and the note says the reader writes no Types and no Locks. Parameters the file does not list are removed, and their Locks with them. A Lock whose parameters stay is untouched. + - New script assertions for page claims that nothing checked yet. The default `remove_parameter` prunes the emptied Parameter Group. `add_instance` keeps an existing value and unit. A submodule with the wrong unit is not an Instance. `remove_type` of a Nested Type is refused. A Globals parameter is saved in the profile. One `pm-type-update` Broadcast per Type edit, and one `pm-lock-update` per Lock method. The D8 refusal of a cross-manager `lock`. Raised mainly by test-reviewer-glm (must-fix), with test-reviewer-qwen, reviewer-glm, reviewer-qwen and plan-checker-qwen. + - Two one-line wordings. The Parameter Manager logs the skipped-Instance warning, not the Server. Ctrl+Shift+Y switches between the two tabs. + + Orchestrator run: ruff clean, script 8/8, same 3 build warnings, 543 in the full suite. +- `b8a99d2` Fix from round 1, two items, script only: + - `d1dcd42` had moved the `remove_lock("q02.IF")` capture ahead of the Target deletion. That dropped the check that a Follower of a surviving Target keeps its Lock, and it left a muddled comment. The script now deletes `q01Data.IF`, asserts `q01.IF` is unlocked with its own value `5000000.0` and that `q02.IF` is still locked to `q01.IF`, and only then captures the one `pm-lock-update` from `remove_lock`. Raised by test-reviewer-glm (must-fix), test-reviewer-qwen (should-fix), reviewer-glm and plan-checker-glm. + - `capture_one_broadcast` returned the snapshot that `cap.wait_for(1)` took when the first message arrived, so its 0.2 s sleep never affected the four `count == 1` checks. It now returns `list(cap.messages)` after the sleep, inside the `with` block. reviewer-qwen raised it alone, and the orchestrator confirmed it in `helpers.py`. + + All six approved in round 2 with no findings. Orchestrator run: ruff clean, script 8/8, same 3 build warnings, 543 in the full suite. + +### Dropped findings +- The page speaks in the present tense about pages that are still stubs: "the Technical Guide page above lists them all" and "server.md explains where profile files end up" (reviewer-glm N2, plan-checker-qwen F6). The links resolve, so this was left for 6.3's final pass. The same nit came up again in round 1 and was not sent. +- Round 2, plan-checker-qwen, not blocking: surviving a restart is covered only indirectly, and `unlock`/`relock`/`lock_type_parameter` have no Broadcast captures of their own. + +### Loose ends +- The five screenshot placeholders (tree with tints, arm strip, Locks panel, Types tab, delete confirmation) are waiting for Marcos to capture them. Each has a ready-to-uncomment `{image}` pair next to it. +- 6.3 should fix the 3 ADR toctree warnings and re-check the stub-page forward references once 6.2 and `server.md` are written. +- New `TEST_AUDIT.md` gaps: the `has_param`, `get`/`set` and `fromFile` docstrings. The `fromFile` row points out that `deleteMissing` never reaches the loader, and the page documents that behaviour with a note. The stale-Proxy `AttributeError` on `_globals` has no direct pytest. + +### Process notes +- plan-checker-qwen wrote its round-0 report but did not send worker_done. One nudge with the exact send command fixed it. +- The watcher's local-port-check rule auto-allowed a chained command from test-reviewer-qwen that also ran an unscanned probe (`probe_shadow.py`). The probe was scanned afterwards, and the rule now rejects chained commands. +- Two permission requests were rejected. test-reviewer-qwen tried to `rm -rf docs/build`, which is shared with the other reviewers, and redid its cleanup without it. plan-checker-qwen mistyped a path outside the repo. +- All six reviewers run the verification script on the helpers' fixed port, so in round 2 several found the port busy. They waited or retried in loops. The `verify_pm_*` directories they saw belonged to other reviewers' runs, not leaks. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index a5fae65..d3845b6 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -611,7 +611,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session ### Phase 6 — Documentation -- [ ] **6.1 User Guide: `docs/user_guide/parameter_manager.md`.** Following the docs +- [x] **6.1 User Guide: `docs/user_guide/parameter_manager.md`.** Following the docs protocol: concept and single source of truth; hierarchical parameters; Types (shape, Instances, Nested Types, `add_instance`); Locks (states, chains, what `set` does); Type Locks and `_globals`; profiles and the version-2 file (with the legacy note); the GUI From 44141d8607dc5909a3091fdcbc234610a47139ca Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 20:29:53 -0500 Subject: [PATCH 090/107] 6.2: Technical Guide Broadcasts page with verification script and audit rows --- TEST_AUDIT.md | 8 + docs/technical_guide/broadcasts.md | 335 ++++++- test/docs_verification/helpers.py | 73 +- .../technical_guide/verify_broadcasts.py | 889 ++++++++++++++++++ 4 files changed, 1296 insertions(+), 9 deletions(-) create mode 100644 test/docs_verification/technical_guide/verify_broadcasts.py diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 7b7512e..4f9bb42 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -20,6 +20,7 @@ States: | installation.md | Install from git (non-editable) | A wheel/sdist build must include the package data dirs (`schemas/`, `deployment/`) or the package cannot even be imported (`__init__.py` opens `schemas/parameters.json` at import time). Found during Phase 2: `[tool.setuptools.package-data]` was missing entirely, so `uv add git+...` produced an uninstallable-in-practice package; fixed in `pyproject.toml`. Editable installs masked this for years | Manual smoke test: `uv add` from a built wheel, import + `paramDictSchema` loaded, data dirs present (Phase 2 grill session) | gap | No CI step builds the wheel and imports from it; an `uv build` + import smoke test would catch any future package-data regression. Also blocks the planned PyPI release if it regresses | | quickstart.md | Starting with a config file | `loadConfig` (`config.py`) parses the YAML and the server creates the declared instruments at startup. Found during Phase 2: an empty or comments-only config file makes `yaml.load()` return `None`, and `loadConfig` then raises a confusing `TypeError: argument of type 'NoneType' is not iterable` instead of a clear "file is empty" message | quickstartConfig.yml via `server_process(config=...)` in verify_quickstart.py | issue-filed | Filed upstream as toolsforexperiments/instrumentserver#139 (related to #109); no pytest covers `loadConfig`'s error paths | | quickstart.md; how_it_works.md | Get/set a parameter / Broadcast reaches subscribers | End-to-end Broadcast: a parameter set on a live `StationServer` is emitted and received by subscribing `SubClient` instances. The how-it-works page also claims that one Broadcast fans out to every listening subscriber | `section_get_set_broadcast` (real server + `capture_broadcasts`/`SubClient`) in verify_quickstart.py | gap | `test_base.py` covers only the pieces in isolation: blueprint encode/decode and `sendBroadcast`/`recvMultipart` over a raw zmq pub/sub pair. Nothing drives a real server set → SubClient receive, and no test proves fan-out to two subscribers. The integration path is untested; strongest candidate for a new integration test | +| broadcasts.md | External forwarding | A Server started with `ipAddresses={"externalBroadcast": "tcp://host:port"}` binds a second PUB socket and sends every Broadcast to both sockets as identical two-frame messages; a `loadConfig` config file's `networking.externalBroadcast` lands in that dict | `section_external_forwarding` in verify_broadcasts.py | gap | `test_config.py` covers only `loadConfig`'s parsing of the `networking` section; no pytest starts a Server with an external Broadcast address and checks the copy arrives (verified here against a raw SUB and a SubClient on the external port) | | quickstart.md | Connect a client and create an instrument | `find_or_create_instrument` is idempotent: a second call with the same name returns a Proxy for the existing instrument and does not import or compare the supplied class path | `section_first_client` in `verify_quickstart.py`; `section_connect_and_get_an_instrument` in `verify_client.py` | covered | `test_connection_discovery_and_shared_server_state` covers the name-based find branch with an intentionally invalid class path | | quickstart.md | Connect a client / starting bare | A freshly started Server with no config owns no instruments: `list_instruments() == []` | `section_start_bare` in `verify_quickstart.py`; `section_connect_and_get_an_instrument` in `verify_client.py` | covered | `test_connection_discovery_and_shared_server_state` asserts the empty initial list on the module's fresh Server | | quickstart.md; how_it_works.md | Starting with a config file / Server owns the instrument | End-to-end server-from-config: starting a real server with `-c ` makes instruments marked `initialize: True` exist the moment it is up, with no client creation call | `section_config_file` (real `instrumentserver -c` subprocess) in verify_quickstart.py | gap | `test_apps.py` *mocks* `loadConfig` and asserts only the `server()` call wiring; `test_config.py` covers parsing only. No test starts a real server from a config and confirms the declared instrument is actually present and usable | @@ -106,3 +107,10 @@ States: | `params.ParameterGroup.has_param` | user_guide/parameter_manager.md (Hierarchical parameters) | No docstring at all; the page (and every client call site) relies on it returning whether the dotted path exists | gap | One line would do: "Whether a parameter exists at the dotted path." | | `params.ParameterGroup.get` / `params.ParameterGroup.set` | user_guide/parameter_manager.md (Type Locks and Globals; Using it from measurement code) | No docstrings. The dotted-path form is the Server-side Parameter Manager's own `get`/`set` (the page reaches the Globals submodule with it through `Client.call`), but on a Proxy Instrument both names resolve to QCoDeS' deprecated local `InstrumentBase.get`/`set`, so the dotted form is unreachable through the Proxy; the docstrings should say which one they are | gap | The shadowing is noted in comments in `test_pm_types.py`; the methods themselves say nothing | | `params.ParameterManager.fromFile` | user_guide/parameter_manager.md (Profiles and files) | The docstring documents `deleteMissing` as if it reached the loader, but `fromFile` never forwards it, so the load always runs with the default `True`; the `filePath=None` description names a file "parametermanager_parameters.json" that the code never uses (it loads the selected profile from the working directory) | gap | The behaviour half is already tracked in the tests table ("Profiles — loading a file"); the page documents the real behaviour with a note | +| `base.sendBroadcast` | technical_guide/broadcasts.md (The wire format) | The docstring's parameter list says `:param messages:` but the parameter is named `message`; it also describes sending "2 messages" where it is one message in two ZMQ frames | gap | The behaviour itself is correct and verified; only the docstring wording is off | +| `base.recvMultipart` | technical_guide/broadcasts.md (SubClient) | First word misspelled ("Recieves"), and the wording is garbled ("Recieves the broadcast from a broadcast message. It should consist of 2 parts: The first item is the name of the object sending it. Second part the message") | gap | It returns a `(topic, decoded_blueprint)` tuple; the wire format section describes the two frames | +| `client.proxy.SubClient.__init__` | technical_guide/broadcasts.md (SubClient) | The `sub_port` docstring says "Should not be changed. It always is the server normal port +1", but every non-default Server requires passing `sub_port = server port + 1` (the Parameter Manager launcher since D24, the GUI models, and the shared helpers all do) | gap | The default (`DEFAULT_PORT + 1`) is only right for a Server on `DEFAULT_PORT` | +| `client.proxy.SubClient.update` (signal doc comment) | technical_guide/broadcasts.md (SubClient) | The `#:` comment says the signal is "emitted when the server broadcast either a new parameter or an update to an existing one", but the signal carries all six actions, including creations, deletions, and the Parameter Manager's `pm-lock-update` / `pm-type-update` | gap | | +| `blueprints.ParameterBroadcastBluePrint` | technical_guide/broadcasts.md (The Parameter Manager's actions) | The class docstring's `value` description covers the parameter actions and the `PMLockBluePrint` of `pm-lock-update`, but omits the `PMTypeBluePrint` (or `None`) payload of `pm-type-update` | gap | | +| `server.core.StationServer._broadcastParameterChange` | technical_guide/broadcasts.md (External forwarding) | Documents only the send to the main Broadcast socket; when an external Broadcast address is configured the same Blueprint is also sent to that second PUB socket | gap | Behaviour verified in verify_broadcasts.py (identical frames on both sockets) | +| `server.core.startServer` | technical_guide/broadcasts.md (External forwarding) | No parameter documentation at all, so the `ipAddresses={"externalBroadcast": ...}` route the page shows is undocumented (as are `stationConfig` and the rest) | gap | | diff --git a/docs/technical_guide/broadcasts.md b/docs/technical_guide/broadcasts.md index 4a6c58e..a039a72 100644 --- a/docs/technical_guide/broadcasts.md +++ b/docs/technical_guide/broadcasts.md @@ -1,12 +1,331 @@ # Broadcasts -:::{admonition} 🚧 This page is planned, not yet written -:class: warning -It will be replaced with verified content as the documentation refactor progresses. -::: +When a Client sets or reads a parameter, the Server announces it as a +**Broadcast**: a small message on a PUB socket that any number of subscribers +receive at once, from the Server's own GUI to a **Listener** on another +machine. Nobody polls. The +[getting started overview](../getting_started/how_it_works.md) shows what +Broadcasts are for; this page is the deeper twin. It covers what exactly +triggers a Broadcast, what the two frames on the wire look like, how a Client +subscribes with `SubClient`, how the Server forwards Broadcasts to other +machines, and how an instrument emits its own Broadcasts through the +**Broadcaster** contract, with the Parameter Manager's Lock, Type and +side-effect actions as the worked example. -This page will cover: +The user-facing side of the Parameter Manager's Broadcasts is in the User +Guide's [Parameter Manager](../user_guide/parameter_manager.md) page, and +Blueprints in general are described in +[Blueprints and Proxies](blueprints_and_proxies.md). -- What triggers a Broadcast; the message format -- SubClient mechanics; how GUIs stay live -- External broadcast forwarding +## What triggers a Broadcast + +The Server emits every Broadcast from the worker thread that executed the +client request, while that worker holds the instrument's instrument mutex. +Requests for one instrument are serialized by that mutex, so the Broadcasts +about one instrument go out in the order the requests ran. + +Four things trigger a Broadcast. A parameter set emits `parameter-update`, +carrying the value that was set. A parameter read emits `parameter-call`, +carrying the value that was read. A client call to a method literally named +`add_parameter` emits `parameter-creation`, and one to `remove_parameter` +emits `parameter-deletion`; the message's `name` is the instrument name and +the call's arguments joined with dots, and a creation carries the +`initial_value` and `unit` keyword arguments the call received. Any other +method call emits nothing. + +The examples below run against a Client and the two instruments they use: + +```pycon +>>> from instrumentserver.client import Client +>>> cli = Client() +>>> dummy = cli.find_or_create_instrument( +... "dummy", +... "instrumentserver.testing.dummy_instruments.generic.DummyInstrumentWithSubmodule", +... ) +>>> pm = cli.find_or_create_instrument( +... "parameter_manager", "instrumentserver.params.ParameterManager" +... ) +``` + +Captured through a `SubClient` (the next section shows the wiring), the +triggers look like this, in the order they were emitted: + +```pycon +>>> dummy.param0.set(0.5) +>>> received[0].action, received[0].name, received[0].value +('parameter-update', 'dummy.param0', 0.5) +>>> dummy.param0() +>>> received[1].action, received[1].value +('parameter-call', 0.5) +>>> pm.add_parameter("extra.gain", initial_value=3, unit="V") +>>> received[2].action, received[2].name, received[2].value, received[2].unit +('parameter-creation', 'parameter_manager.extra.gain', 3, 'V') +>>> pm.remove_parameter("extra.gain") +>>> received[3].action, received[3].name, received[3].value +('parameter-deletion', 'parameter_manager.extra.gain', None) +``` + +That is the whole list for a plain instrument. An instrument that implements +the Broadcaster contract emits its own Broadcasts on top, which is how the +Parameter Manager announces Locks, Types and the parameters it creates as +side effects. + +Every Broadcast carries one of six action strings. They are module constants +in `blueprints.py`, and their values are the wire contract that external +subscribers parse: + +| Constant | Action string | Emitted by | `value` carries | +| --- | --- | --- | --- | +| `PARAMETER_UPDATE` | `parameter-update` | the Server, on a parameter set | the new value | +| `PARAMETER_CALL` | `parameter-call` | the Server, on a parameter read | the value that was read | +| `PARAMETER_CREATION` | `parameter-creation` | the Server, on a client call to `add_parameter`; the Parameter Manager, for parameters it creates as side effects | the initial value (`None` when none was given) | +| `PARAMETER_DELETION` | `parameter-deletion` | the Server, on a client call to `remove_parameter` | nothing (`None`) | +| `PM_LOCK_UPDATE` | `pm-lock-update` | the Parameter Manager, one per affected Follower | the Follower's `PMLockBluePrint`, or `None` when its Lock was removed | +| `PM_TYPE_UPDATE` | `pm-type-update` | the Parameter Manager, one per edited Type | the Type's fresh `PMTypeBluePrint`, or `None` when the Type was removed | + +## The wire format + +The Server publishes Broadcasts on a PUB socket bound to `tcp://*` on the +request port plus one. A Server on the default port 5555 publishes on 5556. + +A Broadcast is a two-frame ZMQ message. Frame 1 is the topic: the instrument +name, which is the first dotted component of the message's `name`. Frame 2 is +the JSON of the message's `ParameterBroadcastBluePrint` dict. Here is one +real message as a subscriber received it, both frames: + +``` +frame 1: 'dummy' +frame 2: {"name": "dummy.param0", "action": "parameter-update", "value": "0.5", "unit": "", "_class_type": "ParameterBroadcastBluePrint"} +``` + +The topic is what lets subscribers filter per instrument: a SUB socket that +subscribes to `parameter_manager` receives the Parameter Manager's Broadcasts +and nothing else. The subscription is a plain prefix match, so subscribing to +`dummy` also receives the messages of an instrument named `dummy2`. One +Broadcast reaches every subscriber: two subscribers on the same socket +received the identical two frames. + +Frame 2 carries every field of the Blueprint as a string, including +`_class_type`, which names the class the dict stands for. `decode` turns the +payload back into the dataclass: `json.loads`, then `deserialize_obj`, which +sees the `_class_type` key and rebuilds a `ParameterBroadcastBluePrint` from +the fields. Decoding the frame 2 payload from above (a subscriber holds it as +`payload`), the numeric strings come back as numbers: + +```pycon +>>> from instrumentserver.base import decode +>>> bp = decode(payload) +>>> type(bp).__name__ +'ParameterBroadcastBluePrint' +>>> bp.name, bp.action, bp.value, bp.unit +('dummy.param0', 'parameter-update', 0.5, '') +``` + +One PUB/SUB quirk to know: a subscriber that connects after a Broadcast was +published misses it. The subscription only counts from the moment it reached +the Server's socket. So subscribe before you trigger, and give a fresh +subscriber a moment to connect before you expect messages from it. The +shared helpers sleep briefly after connecting for exactly this reason. + +## SubClient + +`SubClient` (in `instrumentserver.client.proxy`) is the Qt consumer of +Broadcasts. It opens a SUB socket on the Broadcast port, subscribes to the +instruments you name, or to everything when you pass none, and emits every +received Broadcast as a `ParameterBroadcastBluePrint` on its `update` +signal: + +```python +sub = SubClient(instruments=["parameter_manager"]) # or instruments=None: all +thread = QtCore.QThread() +sub.moveToThread(thread) # the receive loop runs on its own QThread +thread.started.connect(sub.connect) # connect() runs the loop until stop() +sub.finished.connect(thread.quit) +sub.update.connect(on_broadcast) # every Broadcast, as a ParameterBroadcastBluePrint +thread.start() +``` + +That is the exact wiring the Parameter Manager GUI and the shared helpers +use. `stop()` ends the receive loop; stop the thread with it, as the GUI's +`stopListener` does: `sub.stop()`, then `thread.quit()`, then +`thread.wait()`. + +This is also how GUIs stay live without polling. The Parameter Manager GUI's +model receives every Broadcast through a `SubClient` and routes the two +Parameter Manager actions straight into its client-side state: a +`pm-lock-update` payload replaces one Follower's Lock, and a `pm-type-update` +payload replaces that one Type, which carries the whole fresh definition, so +the GUI never fetches that Type or Lock again. The next section shows +the payloads; [the Parameter Manager](../user_guide/parameter_manager.md) +page shows the widgets this feeds. + +You do not need Qt to subscribe. A plain SUB loop is the whole recipe, and +the Listener is exactly that: + +```python +import zmq +from instrumentserver.base import recvMultipart + +context = zmq.Context.instance() +socket = context.socket(zmq.SUB) +socket.connect("tcp://localhost:5556") +socket.setsockopt_string(zmq.SUBSCRIBE, "") +while True: + topic, blueprint = recvMultipart(socket) + # blueprint is a ParameterBroadcastBluePrint, decoded for you + ... +``` + +The [Monitoring](../user_guide/monitoring.md) page covers the Listener and +where it can send what it receives. + +## External forwarding + +By default the Server publishes Broadcasts at `tcp://*` on the request port +plus one, so every machine that can reach the Server can subscribe there. +The station config can additionally name a second address to publish the +same Broadcasts to, in its `networking` section: + +```yaml +networking: + # Adds an address to broadcast parameter changes to, Example: "tcp://192.168.1.1:6000" + externalBroadcast: "tcp://192.168.1.1:6000" +``` + +With that in place the Server binds a second PUB socket on the given address +and sends every Broadcast to both sockets. The copy is the same two-frame +message as the original: a subscriber on the external address received +frames identical to the ones on the local port, and a `SubClient` pointed at +the external address received the Blueprint unchanged. Existing subscribers +need no change; the address is just a second door into the same stream. +That is what puts live UIs and Listeners on other machines: a dedicated +monitoring network or a fixed address of your choosing, with the same +stream on both. + +In code, the same setting is the `ipAddresses` dict: `startServer` accepts +`ipAddresses={"externalBroadcast": "tcp://127.0.0.1:6000"}` and binds the +second PUB socket on it. [The Server](../user_guide/server.md) page covers +the config file and where each entry lands. + +## The Broadcaster contract + +Everything above is the Server broadcasting what it can see: parameter sets +and reads, and calls to methods literally named `add_parameter` and +`remove_parameter`. Structural changes inside an instrument stayed +invisible to everyone else. The Broadcaster contract (ADR-0003) is the +opt-in way for an instrument to announce them itself. + +An instrument implements the contract by mixing in `Broadcaster` (in +`instrumentserver.base`), which brings three methods: + +- `add_broadcast_sink(fn)` registers `fn` to receive every Broadcast the + instrument emits. Sinks are stored in a plain list, so registering the same + sink twice means receiving everything twice. +- `remove_broadcast_sink(fn)` removes it again. +- `broadcast(bp)` sends one `ParameterBroadcastBluePrint` to every + registered sink. With no sinks registered it is a no-op, so an instrument + used standalone (no Server, no sinks) emits nothing and nothing raises. An + exception raised inside one sink is logged and the remaining sinks still + receive the Broadcast. + +The Server knows the contract by shape, not by base class: when an +instrument joins the Station, the Server checks for an +`add_broadcast_sink` attribute and registers its own +`StationServer._broadcastParameterChange` as a sink. This happens at the two +points where instruments enter the Station: created over the wire with +`find_or_create_instrument`, and loaded from the station config at startup. +Instruments without the mixin are registered exactly as before and stay +untouched. + +`broadcast` runs on the thread that called the instrument method. For a +client request that is the worker thread holding the instrument mutex, the +same place the Server's own Broadcasts run from. And the emissions use the +same socket, the same topic (the instrument name) and the same two-frame +wire format as the Server's own Broadcasts, so an existing subscriber parses +them without knowing or caring who produced them. + +The shipping example is the test instrument the verification of this page +uses, `instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument`. +Its emitting method is the whole pattern: + +```python +def emit_broadcast(self, value=1.0, unit="V", action="parameter-update"): + bp = ParameterBroadcastBluePrint( + name=f"{self.name}.param0", action=action, value=value, unit=unit + ) + self.broadcast(bp) + return bp +``` + +The Server registered itself as a sink when the instrument joined the +Station, so `broadcast` put the Blueprint on the Server's PUB socket, and a +`SubClient` received it like any other Broadcast: + +```pycon +>>> bcaster = cli.find_or_create_instrument( +... "bcaster", +... "instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument", +... ) +>>> bcaster.emit_broadcast(value=3.0, unit="V") +>>> received[0].name, received[0].action, received[0].value, received[0].unit +('bcaster.param0', 'parameter-update', 3.0, 'V') +``` + +## The Parameter Manager's actions + +The Parameter Manager is the first instrument built on the Broadcaster +contract. It emits `pm-lock-update` and `pm-type-update`, and re-emits +`parameter-creation` for parameters it creates as side effects. On the wire +its emissions are indistinguishable from the Server's own: same socket, same +topic, same two-frame format. What the actions mean to use is described in +the User Guide's [Parameter Manager](../user_guide/parameter_manager.md) +page, its Locks and Types sections in particular. + +Every Lock method that changes a Lock emits one `pm-lock-update` per +affected Follower (D10). The message's `name` is the Follower's full dotted +path, and its `value` is the Follower's `PMLockBluePrint`, or `None` when +its Lock was removed. So `lock`, `unlock`, `relock`, `toggle_lock` and +`remove_lock` each announce their one change, and `remove_parameter` on a +Target announces every Lock that deleting it drops, one `None` payload per +dropped Follower. The no-op paths emit nothing: unlocking an already +unlocked Lock is not news. Here is the payload of a `pm-lock-update` as it +crossed the wire, stringified fields and all: + +``` +{"name": "parameter_manager.show.follower", "action": "pm-lock-update", "value": {"target": "parameter_manager.show.target", "locked": "True", "_class_type": "PMLockBluePrint"}, "unit": "", "_class_type": "ParameterBroadcastBluePrint"} +``` + +Every Type-editing method emits one `pm-type-update` per affected Type +(D22), including `lock_type_parameter` and `unlock_type_parameter`, which +are Lock methods and Type methods at once. The message's `name` is the +Type's full dotted name, `.`, and its `value` is the +Type's fresh `PMTypeBluePrint` with its entries (defaults, units and Type +Lock Targets), its Nested Types and its effective set, or `None` when the +Type was removed. The payload carries the whole new definition, which is why +a GUI can replace that one Type locally and recompute its tints with no +follow-up fetch: + +``` +{"name": "parameter_manager.display", "action": "pm-type-update", "value": {"name": "display", "parameters": {"gain": {"default": "12", "unit": "dB", "target": "None"}}, "nested": {}, "effective": {"gain": {"unit": "dB", "from_type": "display"}}, "_class_type": "PMTypeBluePrint"}, "unit": "", "_class_type": "ParameterBroadcastBluePrint"} +``` + +Parameters the Parameter Manager creates as side effects are announced as +`parameter-creation`, one per created parameter in creation order: an +`add_type_parameter` that writes an entry into every Instance lacking it, an +`add_instance`, and the Globals Target that a Type Lock with no explicit +Target creates on demand. Direct `add_parameter` and `remove_parameter` +calls keep being announced by the Server, so nothing is ever announced +twice. + +The emissions of one method arrive in a fixed order: + +- `lock_type_parameter` with the default Target: first the + `parameter-creation` of the Globals parameter, then one `pm-lock-update` + per applied Lock, then the Type's `pm-type-update`. +- `add_instance`: first one `parameter-creation` per created parameter, in + creation order, then the `pm-lock-update`s of the Type Locks the new + Instance gets at creation. It edits no Type, so no `pm-type-update`. +- `remove_parameter` on a Target: first one `pm-lock-update` with `None` per + dropped Follower, then one `pm-type-update` per affected Type (the Type + Locks whose stored Target it was), then the `parameter-deletion` that the + Server emits once the call has returned. diff --git a/test/docs_verification/helpers.py b/test/docs_verification/helpers.py index e596080..c698ed5 100644 --- a/test/docs_verification/helpers.py +++ b/test/docs_verification/helpers.py @@ -20,11 +20,13 @@ import socket import subprocess +import threading import time from contextlib import contextmanager -from typing import Any, Iterator, List, Optional, Sequence +from typing import Any, Iterator, List, Optional, Sequence, Tuple import qcodes as qc +import zmq from instrumentserver import DEFAULT_PORT, QtCore, QtWidgets from instrumentserver.client.core import BaseClient @@ -190,6 +192,75 @@ def wait_for(self, n: int = 1, timeout: float = 5.0) -> List[Any]: return list(self.messages) +class RawFrameCapture: + """Collects the raw two-frame ZMQ messages a PUB socket publishes. + + Use via ``capture_raw_frames``. Each entry is a ``(topic, payload)`` + tuple of ``str``: frame 1 is the topic string the publisher chose, + frame 2 the JSON document of the message. + """ + + def __init__(self) -> None: + self.frames: List[Tuple[str, str]] = [] + + def wait_for(self, n: int = 1, timeout: float = 5.0) -> List[Tuple[str, str]]: + """Block until at least ``n`` frame pairs arrived; return them all.""" + deadline = time.monotonic() + timeout + while len(self.frames) < n: + if time.monotonic() > deadline: + raise TimeoutError( + f"Expected {n} raw frame pair(s) within {timeout}s, " + f"got {len(self.frames)}: {self.frames!r}" + ) + time.sleep(0.05) + return list(self.frames) + + +@contextmanager +def capture_raw_frames( + port: int, host: str = "localhost", topic: str = "" +) -> Iterator[RawFrameCapture]: + """Capture the raw two-frame messages a PUB socket publishes. + + A plain ``zmq.SUB`` socket on its own thread, for sections whose claims + are about the wire format itself (``capture_broadcasts`` decodes in the + SubClient, so the frames never reach the caller). + + :param port: the port the PUB socket is bound to. + :param host: host the PUB socket is bound on. + :param topic: subscription prefix; ``""`` receives every message. + """ + capture = RawFrameCapture() + context = zmq.Context.instance() + sock = context.socket(zmq.SUB) + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + sock.connect(f"tcp://{host}:{port}") + sock.setsockopt(zmq.RCVTIMEO, 100) # ms, so the loop can check the stop flag + stop = threading.Event() + + def _collect() -> None: + while not stop.is_set(): + try: + parts = sock.recv_multipart() + except zmq.Again: + continue + capture.frames.append( + (parts[0].decode("utf-8"), parts[1].decode("utf-8")) + ) + + thread = threading.Thread(target=_collect, daemon=True) + thread.start() + # PUB/SUB slow-joiner: give the SUB socket a moment to connect and + # subscribe before the caller triggers the messages it wants to see. + time.sleep(0.3) + try: + yield capture + finally: + stop.set() + thread.join(2) + sock.close(linger=0) + + @contextmanager def capture_broadcasts( instruments: Optional[List[str]] = None, diff --git a/test/docs_verification/technical_guide/verify_broadcasts.py b/test/docs_verification/technical_guide/verify_broadcasts.py new file mode 100644 index 0000000..58a0e93 --- /dev/null +++ b/test/docs_verification/technical_guide/verify_broadcasts.py @@ -0,0 +1,889 @@ +"""Verification script for docs/technical_guide/broadcasts.md. + +One section below per page section, in page order (see +test/docs_verification/README.md for conventions). Asserts every behavioral +claim the page makes; exits 0 on success. + +A Parameter Manager writes its profile files into the working directory of +the process it lives in, so every section that hosts one runs inside a +throwaway working directory created (and removed again) under this script's +folder; no profile file is ever left in the repository. + +The wire-format and external-forwarding claims are observed with raw ZMQ +frames (``capture_raw_frames``); the consumer-side claims go through the +SubClient (``capture_broadcasts``), the intended live-update path. +""" + +import json +import logging +import os +import shutil +import socket +import sys +import tempfile +import threading +import time +from contextlib import contextmanager +from pathlib import Path + +import zmq + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from helpers import ( + DUMMY_INSTRUMENT, + capture_broadcasts, + capture_raw_frames, + client, + ensure_qapp, + server, +) + +from instrumentserver import DEFAULT_PORT, QtCore +from instrumentserver.base import Broadcaster, decode, recvMultipart +from instrumentserver.blueprints import ( + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, + bluePrintToDict, + deserialize_obj, +) +from instrumentserver.client.proxy import SubClient +from instrumentserver.config import loadConfig +from instrumentserver.gui.instruments import PMState +from instrumentserver.params import ParameterManager + +PM_CLASS = "instrumentserver.params.ParameterManager" +PM_NAME = "parameter_manager" +BROADCASTER_CLASS = ( + "instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument" +) +BROADCAST_PORT = DEFAULT_PORT + 1 + + +@contextmanager +def workspace(): + """Run one section in a throwaway working directory. + + The in-process Server (and with it every Parameter Manager it hosts) + writes its profile files into the working directory. The directory is + created under this script's folder and removed again on exit. + """ + old = os.getcwd() + path = Path(tempfile.mkdtemp(prefix="verify_broadcasts_", dir=Path(__file__).parent)) + os.chdir(path) + try: + yield path + finally: + os.chdir(old) + shutil.rmtree(path, ignore_errors=True) + + +def capture_actions(action, instruments=None): + """Run ``action`` under a Broadcast capture and return every message + the capture saw while ``action`` ran, plus a short settle wait. + + ``wait_for(1)`` returns as soon as the first message lands, so the + settle wait runs before the list is taken: a caller counting the + messages sees the ones belonging to this action only. + """ + with capture_broadcasts(instruments) as cap: + action() + cap.wait_for(1) + time.sleep(0.2) + return list(cap.messages) + + +def capture_nothing(action, instruments=None): + """Run ``action`` under a Broadcast capture and assert that nothing + arrives while it runs and during a settle wait.""" + with capture_broadcasts(instruments) as cap: + action() + time.sleep(0.4) + assert cap.messages == [], cap.messages + + +def free_port() -> int: + """A free TCP port on loopback (bind to 0, read it, close).""" + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + if port not in (DEFAULT_PORT, BROADCAST_PORT): + return port + + +# --------------------------------------------------------------------------- +# Section: What triggers a Broadcast +# +# Page claims: the Server emits from the worker thread that executed the +# client request, under that instrument's instrument mutex; parameter-update +# on a parameter set (with the value set), parameter-call on a parameter get +# (with the value read), parameter-creation / parameter-deletion when the +# called method is literally named add_parameter / remove_parameter (with +# name = ., and for creation the initial_value and +# unit keyword arguments); any other method call emits nothing unless the +# instrument is a Broadcaster; the six action strings are the module +# constants in blueprints.py. +# --------------------------------------------------------------------------- +def section_what_triggers_a_broadcast() -> None: + assert PARAMETER_UPDATE == "parameter-update" + assert PARAMETER_CALL == "parameter-call" + assert PARAMETER_CREATION == "parameter-creation" + assert PARAMETER_DELETION == "parameter-deletion" + assert PM_LOCK_UPDATE == "pm-lock-update" + assert PM_TYPE_UPDATE == "pm-type-update" + + with workspace(), server() as srv: + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + + # a parameter set: parameter-update, carrying the value set + with capture_broadcasts() as cap: + dummy.param0.set(0.5) + (bp,) = cap.wait_for(1) + assert bp.name == "dummy.param0" + assert bp.action == PARAMETER_UPDATE + assert bp.value == 0.5 + + # a parameter get: parameter-call, carrying the value read + with capture_broadcasts() as cap: + assert dummy.param0() == 0.5 + (bp,) = cap.wait_for(1) + assert bp.name == "dummy.param0" + assert bp.action == PARAMETER_CALL + assert bp.value == 0.5 + + # a direct add_parameter call: parameter-creation, named + # ., with the initial_value and unit + # keyword arguments + with capture_broadcasts([PM_NAME]) as cap: + pm.add_parameter("extra.gain", initial_value=3, unit="V") + (bp,) = cap.wait_for(1) + assert bp.name == "parameter_manager.extra.gain" + assert bp.action == PARAMETER_CREATION + assert bp.value == 3 + assert bp.unit == "V" + + # a direct remove_parameter call: parameter-deletion + with capture_broadcasts([PM_NAME]) as cap: + pm.remove_parameter("extra.gain") + (bp,) = cap.wait_for(1) + assert bp.name == "parameter_manager.extra.gain" + assert bp.action == PARAMETER_DELETION + assert bp.value is None + + # no initial_value / unit given: value None, unit "" + with capture_broadcasts([PM_NAME]) as cap: + pm.add_parameter("extra.offset") + (bp,) = cap.wait_for(1) + assert bp.name == "parameter_manager.extra.offset" + assert bp.action == PARAMETER_CREATION + assert bp.value is None + assert bp.unit == "" + pm.remove_parameter("extra.offset") + + # any other method call emits nothing: a method that is no + # parameter, and read-only Parameter Manager queries + capture_nothing(lambda: dummy.test_func(1, 2, 3, d=4)) + capture_nothing(lambda: pm.list_types()) + capture_nothing(lambda: pm.has_param("q01.IF")) + + # the Server emits from the worker thread that executed the + # request: a sink on the instrument (the Parameter Manager is a + # Broadcaster) sees a ThreadPoolExecutor worker, not the caller. + # The instrument's sinks run for its own emissions (the Lock + # methods); a plain parameter set is emitted by the Server + # itself, not through the instrument + recorded = [] + + def recording_sink(bp) -> None: + recorded.append(threading.current_thread()) + + pm_instrument = srv.station.components[PM_NAME] + pm_instrument.add_broadcast_sink(recording_sink) + try: + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + cap.wait_for(1) + assert recorded, "the sink never ran" + assert recorded[0] is not threading.current_thread() + assert recorded[0].name.startswith("ThreadPoolExecutor"), ( + recorded[0].name + ) + finally: + pm_instrument.remove_broadcast_sink(recording_sink) + pm.unlock("q01.IF") + + # and the emission happens while the worker holds the + # instrument mutex: a second request for the same instrument + # waits until the first one is done + def blocked_call() -> None: + with client() as second_cli: + finished.append( + second_cli.call(f"{PM_NAME}.set", "q01.IF", 12e6) + ) + + mutex = srv._get_lock_for_target(PM_NAME) + finished = [] + mutex.acquire() + try: + worker = threading.Thread(target=blocked_call, daemon=True) + worker.start() + time.sleep(0.5) + assert finished == [], "the second call was not serialized" + finally: + mutex.release() + worker.join(5) + assert finished != [] + assert pm.q01.IF() == 12000000.0 + print("section_what_triggers_a_broadcast: OK") + + +# --------------------------------------------------------------------------- +# Section: The wire format +# +# Page claims: the PUB socket lives on the request port + 1; a Broadcast is +# a two-frame ZMQ message, frame 1 the topic (the instrument name, the first +# dotted component of the message's name, so subscribers can filter per +# instrument, as a plain prefix match), frame 2 the JSON of the +# ParameterBroadcastBluePrint dict with its _class_type; decode / +# deserialize_obj rebuild the dataclass from that dict; PUB/SUB slow +# joiner: subscribe before you trigger. +# --------------------------------------------------------------------------- +def section_the_wire_format() -> None: + with workspace(), server() as srv: + # the publishing socket is bound on the request port + 1; no + # external forwarding is configured here + assert srv.broadcastPort == BROADCAST_PORT + assert srv.externalBroadcastAddr is None + assert srv.externalBroadcastSocket is None + + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + + with capture_raw_frames(BROADCAST_PORT) as cap: + dummy.param0.set(0.5) + frames = cap.wait_for(1) + topic, payload = frames[0] + print(f"wire format, frame 1 (topic): {topic!r}") + print(f"wire format, frame 2 (payload): {payload}") + + # frame 1: the topic is the instrument name, the first dotted + # component of the message's name + assert topic == "dummy" + + # frame 2: the JSON of the ParameterBroadcastBluePrint dict, + # with its _class_type; every field is on the wire as a string + assert json.loads(payload) == { + "name": "dummy.param0", + "action": "parameter-update", + "value": "0.5", + "unit": "", + "_class_type": "ParameterBroadcastBluePrint", + } + + # decode / deserialize_obj rebuild the dataclass from that dict + bp = deserialize_obj(json.loads(payload)) + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "dummy.param0" + assert bp.action == PARAMETER_UPDATE + assert bp.value == 0.5 # the numeric strings come back as numbers + assert bp.unit == "" + assert decode(payload) == bp # decode does the loads + deserialize + + # and bluePrintToDict is the exact inverse of that rebuild + assert bluePrintToDict(bp) == json.loads(payload) + + # the topic lets subscribers filter per instrument, as a plain + # prefix match: subscribing to "dummy" also receives "dummy2" + dummy2 = cli.find_or_create_instrument("dummy2", DUMMY_INSTRUMENT) + with capture_raw_frames(BROADCAST_PORT, topic="dummy") as cap: + dummy2.param0.set(1) + frames = cap.wait_for(1) + assert frames[0][0] == "dummy2" + + # PUB/SUB slow joiner: a subscriber that connects after the + # trigger misses the message. Subscribe before you trigger. + dummy.param0.set(0.75) + time.sleep(0.2) # the message is published (and gone for late joiners) + with capture_raw_frames(BROADCAST_PORT) as late: + assert late.frames == [] + dummy.param0.set(0.8) + frames = late.wait_for(1) + assert json.loads(frames[0][1])["value"] == "0.8" + + # one Broadcast reaches every subscriber: two captures on the + # same socket both receive the same two frames + with ( + capture_raw_frames(BROADCAST_PORT) as cap_a, + capture_raw_frames(BROADCAST_PORT) as cap_b, + ): + dummy.param0.set(0.9) + frames_a = cap_a.wait_for(1) + frames_b = cap_b.wait_for(1) + assert frames_a == frames_b + print("section_the_wire_format: OK") + + +# --------------------------------------------------------------------------- +# Section: SubClient +# +# Page claims: the SubClient is the Qt consumer: a SUB socket subscribing to +# named instruments or to all, emitting every Broadcast as a +# ParameterBroadcastBluePrint on its update signal, running on its own +# QThread, stoppable with stop(); GUIs stay live by applying the +# pm-lock-update / pm-type-update payloads to their state locally, with no +# follow-up fetch (D22); a non-Qt consumer runs a plain SUB loop like the +# Listener's. +# --------------------------------------------------------------------------- +def section_subclient() -> None: + with workspace(), server(): + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + + # the Qt consumer: subscribe to named instruments, run on its + # own QThread, emit ParameterBroadcastBluePrints on update + ensure_qapp() + received = [] + sub = SubClient(instruments=[PM_NAME]) + sub.update.connect(received.append, QtCore.Qt.DirectConnection) + thread = QtCore.QThread() + sub.moveToThread(thread) + thread.started.connect(sub.connect) + sub.finished.connect(thread.quit) + thread.start() + assert sub.thread() is thread + assert thread is not QtCore.QThread.currentThread() + deadline = time.monotonic() + 5 + while not sub.connected and time.monotonic() < deadline: + time.sleep(0.05) + assert sub.connected + time.sleep(0.3) # PUB/SUB slow joiner + + # another instrument's Broadcast is filtered out by the topic + dummy.param0.set(0.5) + pm.q01.IF.set(11e6) + deadline = time.monotonic() + 5 + while len(received) < 1 and time.monotonic() < deadline: + time.sleep(0.05) + assert len(received) == 1, received + assert isinstance(received[0], ParameterBroadcastBluePrint) + assert received[0].name == "parameter_manager.q01.IF" + assert received[0].action == PARAMETER_UPDATE + + # stop() ends the listener loop; the GUI's stopListener stops + # the thread with it: stop(), thread.quit(), thread.wait() + sub.stop() + thread.quit() + assert thread.wait(2000) + assert not thread.isRunning() + + # instruments=None (the default) subscribes to everything + with capture_broadcasts() as cap: # no instrument names: all + dummy.param0.set(0.6) + pm.q01.IF.set(11.5e6) + messages = cap.wait_for(2) + assert {bp.name for bp in messages} == { + "dummy.param0", + "parameter_manager.q01.IF", + } + + # how GUIs stay live (D22): the Parameter Manager GUI keeps a + # PMState, fills it once, and applies every pm-lock-update / + # pm-type-update payload locally; no follow-up fetch. Here the + # payloads of a second Client's changes are applied by hand and + # must equal a fresh full fetch afterwards. + state = PMState() + state.refresh(pm) + assert state.locks == {} + assert state.types == {} + + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + pm.set_type_parameter_default("qubit", "IF", 12e6) + cap.wait_for(2) + time.sleep(0.2) + for bp in cap.messages: + path = ".".join(bp.name.split(".")[1:]) + if bp.action == PM_LOCK_UPDATE: + state.apply_lock(path, bp.value) + elif bp.action == PM_TYPE_UPDATE: + state.apply_type(path, bp.value) + fresh = PMState() + fresh.refresh(pm) + assert state.locks == fresh.locks + assert state.types == fresh.types + + # a non-Qt consumer: a plain SUB loop like the Listener's, on a + # plain thread, with recvMultipart doing the decode + listener_messages = [] + stop = threading.Event() + + def run_listener() -> None: + context = zmq.Context.instance() + sock = context.socket(zmq.SUB) + sock.connect(f"tcp://localhost:{BROADCAST_PORT}") + sock.setsockopt_string(zmq.SUBSCRIBE, "") + sock.setsockopt(zmq.RCVTIMEO, 100) # ms + try: + while not stop.is_set(): + try: + listener_messages.append(recvMultipart(sock)) + except zmq.Again: + continue + finally: + sock.close(linger=0) + + listener_thread = threading.Thread(target=run_listener, daemon=True) + listener_thread.start() + time.sleep(0.3) # PUB/SUB slow joiner + # the Target accepts set; the locked Follower would refuse it + pm.q01Data.IF.set(13e6) + deadline = time.monotonic() + 5 + while len(listener_messages) < 1 and time.monotonic() < deadline: + time.sleep(0.05) + assert listener_messages, "the plain SUB loop received nothing" + topic, bp = listener_messages[0] + assert topic == PM_NAME + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.q01Data.IF" + assert bp.value == 13000000.0 + stop.set() + listener_thread.join(2) + print("section_subclient: OK") + + +# --------------------------------------------------------------------------- +# Section: External forwarding +# +# Page claims: the station config's ipAddresses.externalBroadcast (a +# "tcp://address:port" string, the networking section of the config file) +# makes the Server bind a second PUB socket there and send every Broadcast +# to both sockets, with the same two frames, so live UIs and Listeners on +# other machines subscribe unchanged. +# --------------------------------------------------------------------------- +def section_external_forwarding() -> None: + with workspace() as workdir: + # the config file form: the networking section's externalBroadcast + # lands in the ipAddresses dict the Server is started with + config = workdir / "extBroadcastConfig.yml" + config.write_text( + "instruments: {}\n" + "networking:\n" + ' externalBroadcast: "tcp://127.0.0.1:6000"\n' + ) + station_cfg, _server_cfg, _full, _shortcuts, temp_file, _rates, addresses = ( + loadConfig(str(config)) + ) + temp_file.close() + assert addresses == {"externalBroadcast": "tcp://127.0.0.1:6000"} + + # the in-process form: startServer takes ipAddresses directly + external_port = free_port() + with server( + ipAddresses={"externalBroadcast": f"tcp://127.0.0.1:{external_port}"} + ) as srv: + assert srv.externalBroadcastAddr == f"tcp://127.0.0.1:{external_port}" + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + + # every Broadcast goes to both sockets, as the same two frames + with ( + capture_raw_frames(BROADCAST_PORT) as main_cap, + capture_raw_frames(external_port) as ext_cap, + ): + dummy.param0.set(0.5) + main_frames = main_cap.wait_for(1) + ext_frames = ext_cap.wait_for(1) + assert ext_frames == main_frames + + # so a consumer on the external address works unchanged + with capture_broadcasts(["dummy"], port=external_port) as cap: + dummy.param0.set(0.7) + (bp,) = cap.wait_for(1) + assert bp.name == "dummy.param0" + assert bp.value == 0.7 + print("section_external_forwarding: OK") + + +# --------------------------------------------------------------------------- +# Section: The Broadcaster contract +# +# Page claims: the Broadcaster mixin (add_broadcast_sink / +# remove_broadcast_sink / broadcast) fans a Broadcast out to its sinks; +# the Server registers _broadcastParameterChange as a sink at the two +# registration points (creation over the wire, loading from the station +# config), by hasattr(instrument, "add_broadcast_sink"); instruments +# without it are untouched; standalone use with no sinks is a no-op; a sink +# that raises is logged and the others still run; Broadcaster emissions use +# the same socket, topic and wire format as the Server's own Broadcasts, so +# existing subscribers need no change; a Broadcaster instrument emits +# through the Server to a SubClient. +# --------------------------------------------------------------------------- +def section_the_broadcaster_contract() -> None: + # the mixin itself, no Server involved + with workspace(): + bc = Broadcaster() + bp = ParameterBroadcastBluePrint( + "bcaster.param0", PARAMETER_UPDATE, 1.0, "V" + ) + + # no sinks: a no-op + bc.broadcast(bp) + + received = [] + bc.add_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp] + + bc.remove_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp] + + # a sink that raises is logged; the others still run + pm = ParameterManager(name="pm_standalone") + seen, errors = [], [] + handler = logging.Handler() + handler.emit = lambda record: errors.append(record.getMessage()) + logger = logging.getLogger("instrumentserver.base") + logger.addHandler(handler) + try: + def failing_sink(bp): + raise RuntimeError("sink is broken") + + pm.add_broadcast_sink(failing_sink) + pm.add_broadcast_sink(seen.append) + pm.broadcast(bp) + finally: + logger.removeHandler(handler) + assert seen == [bp] + assert len(errors) == 1 + assert "broadcast sink" in errors[0], errors + + # standalone use: no sinks registered, so the Parameter Manager's + # own emissions are no-ops and everything works without a Server + pm.add_parameter("own.a", initial_value=1) + pm.add_parameter("own.b", initial_value=2) + pm.lock("own.b", "own.a") + assert pm.get_lock("own.b").locked is True + + # registration point 1: an instrument created over the wire + with workspace(), server() as srv: + with client() as cli: + bcaster = cli.find_or_create_instrument("bcaster", BROADCASTER_CLASS) + instrument = srv.station.components["bcaster"] + assert srv._broadcastParameterChange in instrument._broadcast_sinks + + # instruments without the mixin are untouched + cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + assert not hasattr(srv.station.components["dummy"], "add_broadcast_sink") + + # same socket, topic and wire format as the Server's own + # Broadcasts: two frames, topic = instrument name, JSON with + # the _class_type + with capture_raw_frames(BROADCAST_PORT) as cap: + bcaster.emit_broadcast(value=2.5, unit="V") + frames = cap.wait_for(1) + topic, payload = frames[0] + assert topic == "bcaster" + assert json.loads(payload) == { + "name": "bcaster.param0", + "action": PARAMETER_UPDATE, + "value": "2.5", + "unit": "V", + "_class_type": "ParameterBroadcastBluePrint", + } + + # the minimal example: the Broadcaster instrument emits through + # the Server, and a SubClient receives the blueprint unchanged + with capture_broadcasts(["bcaster"]) as cap: + bcaster.emit_broadcast(value=3.0, unit="V") + (bp,) = cap.wait_for(1) + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "bcaster.param0" + assert bp.action == PARAMETER_UPDATE + assert bp.value == 3.0 + assert bp.unit == "V" + + # registration point 2: an instrument loaded from the station config + with workspace() as workdir: + config = workdir / "broadcasterConfig.yml" + config.write_text( + "instruments:\n" + " cfg_bcaster:\n" + " type: instrumentserver.testing.dummy_instruments.generic." + "DummyBroadcasterInstrument\n" + " initialize: True\n" + ) + station_cfg, server_cfg, _full, _shortcuts, temp_file, _rates, _addresses = ( + loadConfig(str(config)) + ) + try: + with server(config=station_cfg, serverConfig=server_cfg) as srv: + assert "cfg_bcaster" in srv.station.components + component = srv.station.components["cfg_bcaster"] + assert srv._broadcastParameterChange in component._broadcast_sinks + finally: + temp_file.close() + print("section_the_broadcaster_contract: OK") + + +# --------------------------------------------------------------------------- +# Section: The Parameter Manager's actions +# +# Page claims: pm-lock-update, one per affected Follower, name = full +# Follower path, value = PMLockBluePrint(target, locked) or None when the +# Lock was removed, emitted by every Lock method and by remove_parameter +# when deleting a Target drops Locks (D10); pm-type-update, name = +# ., value = PMTypeBluePrint(name, parameters, nested, +# effective) or None when the Type was removed, from every Type-editing +# method including lock_type_parameter / unlock_type_parameter (D22); +# re-emitted parameter-creation for parameters the Parameter Manager +# creates as side effects, while direct add_parameter / remove_parameter +# calls are announced by the Server, so nothing is announced twice; the +# ordering rules the docstrings state. +# --------------------------------------------------------------------------- +def section_the_parameter_managers_actions() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e6, unit="Hz") + + # pm-lock-update: one per affected Follower, name = full + # Follower path, value = PMLockBluePrint(target, locked) + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + (bp,) = cap.wait_for(1) + assert bp.action == PM_LOCK_UPDATE + assert bp.name == "parameter_manager.q01.IF" + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + # every Lock method announces its state change exactly once + messages = capture_actions(lambda: pm.unlock("q01.IF")) + assert len(messages) == 1 + assert messages[0].value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + messages = capture_actions(lambda: pm.relock("q01.IF")) + assert len(messages) == 1 and messages[0].value.locked is True + messages = capture_actions(lambda: pm.toggle_lock("q01.IF")) + assert len(messages) == 1 and messages[0].value.locked is False + messages = capture_actions(lambda: pm.toggle_lock("q01.IF")) + assert len(messages) == 1 and messages[0].value.locked is True + + # the no-op path emits nothing: unlocking an unlocked Lock + pm.unlock("q01.IF") + capture_nothing(lambda: pm.unlock("q01.IF"), [PM_NAME]) + pm.relock("q01.IF") + + # remove_lock announces the removal with a None payload + messages = capture_actions(lambda: pm.remove_lock("q01.IF")) + assert len(messages) == 1 + assert messages[0].action == PM_LOCK_UPDATE + assert messages[0].name == "parameter_manager.q01.IF" + assert messages[0].value is None + pm.lock("q01.IF", "q01Data.IF") + + # deleting a Target: one pm-lock-update with None per dropped + # Follower, then the Server's parameter-deletion (the deletion + # itself emits nothing in the Parameter Manager) + messages = capture_actions(lambda: pm.remove_parameter("q01Data.IF")) + actions = [bp.action for bp in messages] + assert actions == [PM_LOCK_UPDATE, PARAMETER_DELETION], actions + assert messages[0].name == "parameter_manager.q01.IF" + assert messages[0].value is None + assert messages[1].name == "parameter_manager.q01Data.IF" + + # pm-type-update: name = ., value = the fresh + # PMTypeBluePrint, one per Type edit. (The Type's entry is named + # gain, not IF: the lock-demo submodule q01 carries q01.IF in + # Hz, and a Type requiring IF would duck-type it into an + # Instance and broadcast about it too.) + messages = capture_actions(lambda: pm.add_type("qubit")) + assert len(messages) == 1 + assert messages[0].action == PM_TYPE_UPDATE + assert messages[0].name == "parameter_manager.qubit" + assert messages[0].value == PMTypeBluePrint( + name="qubit", parameters={}, nested={}, effective={} + ) + + # no Instances yet, so an entry edit emits only the Type update + messages = capture_actions( + lambda: pm.add_type_parameter("qubit", "gain", default=10, unit="dB") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + + # an Instance exists: the entry written into it is announced as + # a parameter-creation first, then the Type update + pm.add_instance("qubit", "q02") + pm.update() + messages = capture_actions( + lambda: pm.add_type_parameter("qubit", "window", default=0.5, unit="s") + ) + assert [bp.action for bp in messages] == [ + PARAMETER_CREATION, + PM_TYPE_UPDATE, + ], [ + (bp.action, bp.name, bp.value, bp.unit) for bp in messages + ] + assert messages[0].name == "parameter_manager.q02.window" + assert messages[0].value == 0.5 + assert messages[0].unit == "s" + assert messages[1].name == "parameter_manager.qubit" + assert messages[1].value.name == "qubit" + assert messages[1].value.parameters["window"] == { + "default": 0.5, + "unit": "s", + "target": None, + } + + # add_instance announces every parameter it creates, in + # creation order, and no pm-type-update: it edits no Type + messages = capture_actions(lambda: pm.add_instance("qubit", "q03")) + actions = [bp.action for bp in messages] + assert actions == [PARAMETER_CREATION, PARAMETER_CREATION], actions + assert [bp.name for bp in messages] == [ + "parameter_manager.q03.gain", + "parameter_manager.q03.window", + ] + + # remove_type announces the removal with a None payload + pm.add_type("shortlived") + messages = capture_actions(lambda: pm.remove_type("shortlived")) + assert len(messages) == 1 + assert messages[0].action == PM_TYPE_UPDATE + assert messages[0].name == "parameter_manager.shortlived" + assert messages[0].value is None + + # a Type Lock with the default Globals Target: the created + # Globals parameter first, then one pm-lock-update per applied + # Lock, then the pm-type-update of the edited Type + pm.add_type("readout") + pm.add_type_parameter("readout", "bw", default=20e6, unit="Hz") + pm.add_instance("readout", "r01") + pm.add_instance("readout", "r02") + messages = capture_actions(lambda: pm.lock_type_parameter("readout", "bw")) + actions = [bp.action for bp in messages] + assert actions == [ + PARAMETER_CREATION, + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ], actions + assert messages[0].name == "parameter_manager._globals.readout.bw" + assert messages[0].value == 20000000.0 + assert {bp.name for bp in messages[1:3]} == { + "parameter_manager.r01.bw", + "parameter_manager.r02.bw", + } + assert all(bp.value.locked for bp in messages[1:3]) + assert messages[3].name == "parameter_manager.readout" + + # unlock_type_parameter: exactly one pm-type-update, the Locks stay + messages = capture_actions( + lambda: pm.unlock_type_parameter("readout", "bw") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].value.parameters["bw"]["target"] is None + + # a new Instance of a Type with a Type Lock: the creation, then + # the Lock the new Instance gets at creation; no Type update + pm.lock_type_parameter("readout", "bw") + messages = capture_actions(lambda: pm.add_instance("readout", "r03")) + actions = [bp.action for bp in messages] + assert actions == [PARAMETER_CREATION, PM_LOCK_UPDATE], actions + assert messages[0].name == "parameter_manager.r03.bw" + assert messages[1].name == "parameter_manager.r03.bw" + assert messages[1].value == PMLockBluePrint( + target="parameter_manager._globals.readout.bw", locked=True + ) + + # a direct add_parameter is announced by the Server, exactly + # once: the Parameter Manager adds nothing of its own + messages = capture_actions( + lambda: pm.add_parameter("q01.power", initial_value=-10, unit="dBm"), + [PM_NAME], + ) + assert len(messages) == 1 + assert messages[0].action == PARAMETER_CREATION + assert messages[0].name == "parameter_manager.q01.power" + + # ... and a direct remove_parameter is announced once, too + messages = capture_actions( + lambda: pm.remove_parameter("q01.power"), [PM_NAME] + ) + assert len(messages) == 1 + assert messages[0].action == PARAMETER_DELETION + + # a Target that a Type Lock rule points at: the dropped Lock + # (pm-lock-update with None) first, then the pm-type-update of + # the cleared rule, then the Server's parameter-deletion + pm.add_parameter("shared.frequency", initial_value=1e6, unit="Hz") + pm.add_type("receiver") + pm.add_type_parameter("receiver", "lo", default=1e6, unit="Hz") + pm.add_instance("receiver", "rcv01") + pm.lock_type_parameter("receiver", "lo", target="shared.frequency") + messages = capture_actions(lambda: pm.remove_parameter("shared.frequency")) + actions = [bp.action for bp in messages] + assert actions == [ + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PARAMETER_DELETION, + ], actions + assert messages[0].name == "parameter_manager.rcv01.lo" + assert messages[0].value is None + assert messages[1].name == "parameter_manager.receiver" + assert messages[1].value.parameters["lo"]["target"] is None + assert messages[2].name == "parameter_manager.shared.frequency" + + # the two payload blueprints as they appear on the wire + pm.add_parameter("show.target", initial_value=1.0, unit="V") + pm.add_parameter("show.follower", initial_value=0.0, unit="V") + with capture_raw_frames(BROADCAST_PORT, topic=PM_NAME) as cap: + pm.lock("show.follower", "show.target") + frames = cap.wait_for(1) + lock_payload = frames[0][1] + print(f"pm-lock-update payload: {lock_payload}") + wire = json.loads(lock_payload) + assert wire["action"] == PM_LOCK_UPDATE + assert wire["value"]["_class_type"] == "PMLockBluePrint" + + pm.add_type("display") + pm.add_type_parameter("display", "gain", default=10, unit="dB") + with capture_raw_frames(BROADCAST_PORT, topic=PM_NAME) as cap: + pm.set_type_parameter_default("display", "gain", 12) + frames = cap.wait_for(1) + type_payload = frames[0][1] + print(f"pm-type-update payload: {type_payload}") + wire = json.loads(type_payload) + assert wire["action"] == PM_TYPE_UPDATE + assert wire["value"]["_class_type"] == "PMTypeBluePrint" + print("section_the_parameter_managers_actions: OK") + + +if __name__ == "__main__": + section_what_triggers_a_broadcast() + section_the_wire_format() + section_subclient() + section_external_forwarding() + section_the_broadcaster_contract() + section_the_parameter_managers_actions() + print("verify_broadcasts: all sections OK") From da12f46fe222355e07871f0b84bb0188007b1578 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 21:43:54 -0500 Subject: [PATCH 091/107] 6.2: fix from review round 1: pin PM payloads field by field, cover every Type-editing method, double-registration and wording fixes --- docs/technical_guide/broadcasts.md | 70 ++++++----- .../technical_guide/verify_broadcasts.py | 116 +++++++++++++++--- 2 files changed, 142 insertions(+), 44 deletions(-) diff --git a/docs/technical_guide/broadcasts.md b/docs/technical_guide/broadcasts.md index a039a72..3b5c051 100644 --- a/docs/technical_guide/broadcasts.md +++ b/docs/technical_guide/broadcasts.md @@ -47,7 +47,7 @@ The examples below run against a Client and the two instruments they use: ... ) ``` -Captured through a `SubClient` (the next section shows the wiring), the +Captured through a `SubClient` (the SubClient section shows the wiring), the triggers look like this, in the order they were emitted: ```pycon @@ -102,15 +102,17 @@ The topic is what lets subscribers filter per instrument: a SUB socket that subscribes to `parameter_manager` receives the Parameter Manager's Broadcasts and nothing else. The subscription is a plain prefix match, so subscribing to `dummy` also receives the messages of an instrument named `dummy2`. One -Broadcast reaches every subscriber: two subscribers on the same socket -received the identical two frames. - -Frame 2 carries every field of the Blueprint as a string, including -`_class_type`, which names the class the dict stands for. `decode` turns the -payload back into the dataclass: `json.loads`, then `deserialize_obj`, which -sees the `_class_type` key and rebuilds a `ParameterBroadcastBluePrint` from -the fields. Decoding the frame 2 payload from above (a subscriber holds it as -`payload`), the numeric strings come back as numbers: +Broadcast reaches every subscriber: two SUB sockets connected to the +Server's PUB socket each received the identical two frames. + +Frame 2 carries every scalar field of the Blueprint as a string, including +`_class_type`, which names the class the dict stands for. A `value` that is +itself a Blueprint travels as a nested dict with its own `_class_type`, as +the Parameter Manager payloads at the end of this page show. `decode` turns +the payload back into the dataclass: `json.loads`, then `deserialize_obj`, +which sees the `_class_type` key and rebuilds a `ParameterBroadcastBluePrint` +from the fields. Decoding the frame 2 payload from above (a subscriber holds +it as `payload`), the numeric strings come back as numbers: ```pycon >>> from instrumentserver.base import decode @@ -151,13 +153,14 @@ use. `stop()` ends the receive loop; stop the thread with it, as the GUI's `thread.wait()`. This is also how GUIs stay live without polling. The Parameter Manager GUI's -model receives every Broadcast through a `SubClient` and routes the two -Parameter Manager actions straight into its client-side state: a -`pm-lock-update` payload replaces one Follower's Lock, and a `pm-type-update` -payload replaces that one Type, which carries the whole fresh definition, so -the GUI never fetches that Type or Lock again. The next section shows -the payloads; [the Parameter Manager](../user_guide/parameter_manager.md) -page shows the widgets this feeds. +model receives every Broadcast of its instrument through a `SubClient` and +routes the two Parameter Manager actions straight into its client-side +state: a `pm-lock-update` payload replaces one Follower's Lock, and a +`pm-type-update` payload replaces that one Type, which carries the whole +fresh definition, so the GUI never fetches that Type or Lock again. The +Parameter Manager's actions section at the end of this page shows the +payloads; [the Parameter Manager](../user_guide/parameter_manager.md) page +shows the widgets this feeds. You do not need Qt to subscribe. A plain SUB loop is the whole recipe, and the Listener is exactly that: @@ -220,7 +223,8 @@ An instrument implements the contract by mixing in `Broadcaster` (in - `add_broadcast_sink(fn)` registers `fn` to receive every Broadcast the instrument emits. Sinks are stored in a plain list, so registering the same - sink twice means receiving everything twice. + sink twice means receiving everything twice, and removing it once leaves it + registered once. - `remove_broadcast_sink(fn)` removes it again. - `broadcast(bp)` sends one `ParameterBroadcastBluePrint` to every registered sink. With no sinks registered it is a no-op, so an instrument @@ -296,14 +300,15 @@ crossed the wire, stringified fields and all: ``` Every Type-editing method emits one `pm-type-update` per affected Type -(D22), including `lock_type_parameter` and `unlock_type_parameter`, which -are Lock methods and Type methods at once. The message's `name` is the -Type's full dotted name, `.`, and its `value` is the -Type's fresh `PMTypeBluePrint` with its entries (defaults, units and Type -Lock Targets), its Nested Types and its effective set, or `None` when the -Type was removed. The payload carries the whole new definition, which is why -a GUI can replace that one Type locally and recompute its tints with no -follow-up fetch: +(D22), the two Type Lock methods included. `lock_type_parameter` on top +creates the Locks it declares, so it additionally emits one +`pm-lock-update` per applied Lock; `unlock_type_parameter` removes only the +rule. The message's `name` is the Type's full dotted name, +`.`, and its `value` is the Type's fresh +`PMTypeBluePrint` with its entries (defaults, units and Type Lock Targets), +its Nested Types and its effective set, or `None` when the Type was removed. +The payload carries the whole new definition, which is why a GUI can replace +that one Type locally and recompute its tints with no follow-up fetch: ``` {"name": "parameter_manager.display", "action": "pm-type-update", "value": {"name": "display", "parameters": {"gain": {"default": "12", "unit": "dB", "target": "None"}}, "nested": {}, "effective": {"gain": {"unit": "dB", "from_type": "display"}}, "_class_type": "PMTypeBluePrint"}, "unit": "", "_class_type": "ParameterBroadcastBluePrint"} @@ -312,16 +317,21 @@ follow-up fetch: Parameters the Parameter Manager creates as side effects are announced as `parameter-creation`, one per created parameter in creation order: an `add_type_parameter` that writes an entry into every Instance lacking it, an -`add_instance`, and the Globals Target that a Type Lock with no explicit -Target creates on demand. Direct `add_parameter` and `remove_parameter` -calls keep being announced by the Server, so nothing is ever announced -twice. +`add_nested_type` that writes the Nested Type's entries under its submodule +into every Instance of the outer Type lacking them, an `add_instance`, and +the Globals Target that a Type Lock with no explicit Target creates on +demand. Direct `add_parameter` and `remove_parameter` calls keep being +announced by the Server, so nothing is ever announced twice. The emissions of one method arrive in a fixed order: - `lock_type_parameter` with the default Target: first the `parameter-creation` of the Globals parameter, then one `pm-lock-update` per applied Lock, then the Type's `pm-type-update`. +- `add_nested_type`: first one `parameter-creation` per created parameter, + in creation order, then the `pm-lock-update`s of the Type Locks the new + Instances get, then one `pm-type-update` per affected Type, the edited + Type first. - `add_instance`: first one `parameter-creation` per created parameter, in creation order, then the `pm-lock-update`s of the Type Locks the new Instance gets at creation. It edits no Type, so no `pm-type-update`. diff --git a/test/docs_verification/technical_guide/verify_broadcasts.py b/test/docs_verification/technical_guide/verify_broadcasts.py index 58a0e93..22a44ee 100644 --- a/test/docs_verification/technical_guide/verify_broadcasts.py +++ b/test/docs_verification/technical_guide/verify_broadcasts.py @@ -548,12 +548,19 @@ def section_the_broadcaster_contract() -> None: received = [] bc.add_broadcast_sink(received.append) + # the same sink twice: sinks are a plain list, so it receives + # every Broadcast twice, and a single remove leaves it in once + bc.add_broadcast_sink(received.append) bc.broadcast(bp) - assert received == [bp] + assert received == [bp, bp] bc.remove_broadcast_sink(received.append) bc.broadcast(bp) - assert received == [bp] + assert received == [bp, bp, bp] + + bc.remove_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp, bp, bp] # a sink that raises is logged; the others still run pm = ParameterManager(name="pm_standalone") @@ -652,11 +659,16 @@ def failing_sink(bp): # when deleting a Target drops Locks (D10); pm-type-update, name = # ., value = PMTypeBluePrint(name, parameters, nested, # effective) or None when the Type was removed, from every Type-editing -# method including lock_type_parameter / unlock_type_parameter (D22); -# re-emitted parameter-creation for parameters the Parameter Manager -# creates as side effects, while direct add_parameter / remove_parameter -# calls are announced by the Server, so nothing is announced twice; the -# ordering rules the docstrings state. +# method (add_type, add_type_parameter, set_type_parameter_default, +# set_type_parameter_unit, remove_type_parameter, add_nested_type, +# remove_nested_type, remove_type, and the Type Lock methods +# lock_type_parameter / unlock_type_parameter, D22), including the +# documented order of add_nested_type; re-emitted parameter-creation for +# parameters the Parameter Manager creates as side effects, while direct +# add_parameter / remove_parameter calls are announced by the Server, so +# nothing is announced twice; the two payload blueprints pinned field by +# field as they appear on the wire; the ordering rules the docstrings +# state. # --------------------------------------------------------------------------- def section_the_parameter_managers_actions() -> None: with workspace(), server(): @@ -765,6 +777,59 @@ def section_the_parameter_managers_actions() -> None: "parameter_manager.q03.window", ] + # the remaining Type-editing methods announce their Type too: + # exactly one pm-type-update each (no Types nest them here) + messages = capture_actions( + lambda: pm.set_type_parameter_unit("qubit", "gain", "V") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].name == "parameter_manager.qubit" + + messages = capture_actions( + lambda: pm.remove_type_parameter("qubit", "window") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].name == "parameter_manager.qubit" + + # add_nested_type, on a Type with one Instance and a Nested + # Type carrying a Type Lock: one parameter-creation per + # created parameter, in creation order, then the pm-lock-updates + # of the Type Locks the new Instances get, then one + # pm-type-update per affected Type, the edited Type first + pm.add_type("pulse") + pm.add_type_parameter("pulse", "length", default=0.5, unit="s") + pm.add_instance("pulse", "p01") + pm.lock_type_parameter("pulse", "length") + pm.add_type("qubitline") + pm.add_type_parameter("qubitline", "drive", default=1.0, unit="dBm") + pm.add_instance("qubitline", "ql01") + pm.update() + messages = capture_actions( + lambda: pm.add_nested_type("qubitline", "readout", "pulse") + ) + actions = [bp.action for bp in messages] + assert actions == [ + PARAMETER_CREATION, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ], actions + assert messages[0].name == "parameter_manager.ql01.readout.length" + assert messages[0].value == 0.5 + assert messages[0].unit == "s" + assert messages[1].name == "parameter_manager.ql01.readout.length" + assert messages[1].value == PMLockBluePrint( + target="parameter_manager._globals.pulse.length", locked=True + ) + assert messages[2].name == "parameter_manager.qubitline" + + # removing the Nested Type requirement announces the Type once + messages = capture_actions( + lambda: pm.remove_nested_type("qubitline", "readout") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].name == "parameter_manager.qubitline" + assert messages[0].value.nested == {} + # remove_type announces the removal with a None payload pm.add_type("shortlived") messages = capture_actions(lambda: pm.remove_type("shortlived")) @@ -854,7 +919,8 @@ def section_the_parameter_managers_actions() -> None: assert messages[1].value.parameters["lo"]["target"] is None assert messages[2].name == "parameter_manager.shared.frequency" - # the two payload blueprints as they appear on the wire + # the two payload blueprints as they appear on the wire, + # pinned field by field (these are the page's two examples) pm.add_parameter("show.target", initial_value=1.0, unit="V") pm.add_parameter("show.follower", initial_value=0.0, unit="V") with capture_raw_frames(BROADCAST_PORT, topic=PM_NAME) as cap: @@ -862,9 +928,17 @@ def section_the_parameter_managers_actions() -> None: frames = cap.wait_for(1) lock_payload = frames[0][1] print(f"pm-lock-update payload: {lock_payload}") - wire = json.loads(lock_payload) - assert wire["action"] == PM_LOCK_UPDATE - assert wire["value"]["_class_type"] == "PMLockBluePrint" + assert json.loads(lock_payload) == { + "name": "parameter_manager.show.follower", + "action": PM_LOCK_UPDATE, + "value": { + "target": "parameter_manager.show.target", + "locked": "True", + "_class_type": "PMLockBluePrint", + }, + "unit": "", + "_class_type": "ParameterBroadcastBluePrint", + } pm.add_type("display") pm.add_type_parameter("display", "gain", default=10, unit="dB") @@ -873,9 +947,23 @@ def section_the_parameter_managers_actions() -> None: frames = cap.wait_for(1) type_payload = frames[0][1] print(f"pm-type-update payload: {type_payload}") - wire = json.loads(type_payload) - assert wire["action"] == PM_TYPE_UPDATE - assert wire["value"]["_class_type"] == "PMTypeBluePrint" + assert json.loads(type_payload) == { + "name": "parameter_manager.display", + "action": PM_TYPE_UPDATE, + "value": { + "name": "display", + "parameters": { + "gain": {"default": "12", "unit": "dB", "target": "None"} + }, + "nested": {}, + "effective": { + "gain": {"unit": "dB", "from_type": "display"} + }, + "_class_type": "PMTypeBluePrint", + }, + "unit": "", + "_class_type": "ParameterBroadcastBluePrint", + } print("section_the_parameter_managers_actions: OK") From a6e866c4c52c1b576744967c8d861f9e24efe3fe Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 22:29:26 -0500 Subject: [PATCH 092/107] 6.2: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 42 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 5f13d8d..00a8554 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -1032,3 +1032,45 @@ The stub `docs/user_guide/parameter_manager.md` is now the full User Guide page. - The watcher's local-port-check rule auto-allowed a chained command from test-reviewer-qwen that also ran an unscanned probe (`probe_shadow.py`). The probe was scanned afterwards, and the rule now rejects chained commands. - Two permission requests were rejected. test-reviewer-qwen tried to `rm -rf docs/build`, which is shared with the other reviewers, and redid its cleanup without it. plan-checker-qwen mistyped a path outside the repo. - All six reviewers run the verification script on the helpers' fixed port, so in round 2 several found the port busy. They waited or retried in loops. The `verify_pm_*` directories they saw belonged to other reviewers' runs, not leaks. + +## 6.2 Technical Guide: `docs/technical_guide/broadcasts.md` — 2026-09-28 + +The stub `docs/technical_guide/broadcasts.md` is now the full Technical Guide page. It has six level-2 sections: "What triggers a Broadcast", "The wire format", "SubClient", "External forwarding", "The Broadcaster contract" and "The Parameter Manager's actions". It links to the User Guide's Parameter Manager page, `server.md`, `monitoring.md` and `blueprints_and_proxies.md`. The verification script `test/docs_verification/technical_guide/verify_broadcasts.py` has one `section_*` function per page section and runs in a throwaway `verify_broadcasts_*` directory. The shared helpers gained `capture_raw_frames` and `RawFrameCapture`, a plain `zmq.SUB` on its own thread, because `capture_broadcasts` decodes inside the `SubClient` and never hands over the raw frames. The task added seven docstring rows and one tests row to `TEST_AUDIT.md`, and changed no source code. + +### Commit by commit +- `44141d8` The page, the script, the helper and the `TEST_AUDIT.md` rows. The orchestrator's coder spec set eight readings. The main ones: + - Reading 1 carried over 6.1's meaning of "following the docs protocol": the script exercises every claim first, no build warnings from this page, the docs plan's style rules, the reviewers in place of GRILL/REVISE, and one commit by the coder. + - Reading 2 named the script `verify_broadcasts.py`, since the plan gives only the folder. + - Reading 3 fixed the six sections and what each covers. The wire format is shown twice: as the two raw frames a plain SUB socket receives (topic = instrument name, then the JSON of the `ParameterBroadcastBluePrint` dict with `_class_type`), and as the Blueprint that `decode`/`deserialize_obj` rebuild from it. + - Reading 6 verifies external forwarding in-process: `startServer(ipAddresses={"externalBroadcast": "tcp://127.0.0.1:"})` through the helpers' `server(**kwargs)`, and a raw SUB on that address that receives the same two frames as the main socket. + - Reading 5 sent docstring problems to `TEST_AUDIT.md` instead of edits. The docstring rows are `base.sendBroadcast` (`:param messages:` for a parameter named `message`), `base.recvMultipart` ("Recieves" and garbled wording), `SubClient.__init__` (the `sub_port` "should not be changed" note is wrong for any non-default Server), the `SubClient.update` signal comment (it names two of the six actions), `ParameterBroadcastBluePrint` (it omits the `PMTypeBluePrint` payload), `StationServer._broadcastParameterChange` (it does not mention the external socket) and `startServer` (no parameter docs at all). The tests row: no pytest starts a Server with an external Broadcast address. + + Orchestrator run: ruff clean, script 6/6 sections OK, docs build with only the 3 old ADR warnings, 543 in the full suite. The script's log also shows a non-fatal `NotImplementedError` traceback from the Server asking the config-loaded `DummyBroadcasterInstrument` for its IDN. +- `da12f46` Fix from round 0, four items. The commit message says "round 1", but the fix list is `round-0/fix-list.md`. Five of the six reviewers asked for changes, and plan-checker-qwen approved with five nits: + - The page said registering the same sink twice means receiving everything twice, and nothing checked it. The script now adds the sink twice, sees two deliveries, removes it once and sees one more delivery, then removes it again and sees none. The page adds "removing it once leaves it registered once". Raised by plan-checker-glm, reviewer-qwen, test-reviewer-glm (must-fix) and test-reviewer-qwen. + - The `pm-lock-update` and `pm-type-update` payloads had been checked only by `action` and the inner `_class_type`. Both are now compared as complete dicts equal to the page's two examples (`"locked": "True"`, `"target": "None"`, the `effective` map and so on). Both test reviewers raised it (should-fix). + - `add_nested_type` creates parameters as a side effect (`params.py` calls `_broadcast_parameter_creation` there), but the page left it out of the list of side-effect creators, and four Type-editing methods had no capture. The page now lists it and gives its order: `parameter-creation`s, then the applied Type Locks' `pm-lock-update`s, then one `pm-type-update` per affected Type, the edited Type first. The script now asserts one `pm-type-update` each for `set_type_parameter_unit`, `remove_type_parameter` and `remove_nested_type`, and the `add_nested_type` order on Type `qubitline` with Instance `ql01` and a Nested Type `pulse` that carries a Type Lock. Raised by plan-checker-glm, reviewer-glm (must-fix), test-reviewer-qwen (should-fix) and plan-checker-qwen. + - Five one-line wording fixes. By severity these were nits, but each was a factual slip on a wire-format page, and a fix round was happening anyway: + - A "next section" pointer that was two sections off. + - Frame 2 carries only the scalar fields as strings. A Blueprint `value` travels as a nested dict. + - The fan-out claim now says two SUB sockets each received the frames, which is what the script runs. + - `lock_type_parameter`/`unlock_type_parameter` are no longer called "Lock methods and Type methods at once". `unlock_type_parameter` removes only the rule and touches no Lock. + - The GUI's `SubClient` receives every Broadcast of its own instrument, not every Broadcast. + + All six approved in round 1. Orchestrator run: ruff clean, script 6/6, same 3 build warnings, 543 in the full suite. + +### Dropped findings +- The page's opening sentence names the Server's own GUI as a subscriber (reviewer-glm). Not sent: its embedded instrument widgets, such as `ParameterManagerGui`, do subscribe with a `SubClient`, and `how_it_works.md` uses the same wording. +- Not sent from test-reviewer-glm: the page's claim that emission happens under the held instrument mutex is not observed directly (hard to test cleanly), and `hasattr` versus `isinstance` registration cannot be told apart (no duck-typed instrument class exists). +- Style nits not sent: lowercase "client" in four places (the site is mixed, and `quickstart.md` uses lowercase), and "the instrument's instrument mutex" (both reviewer-qwen). +- Round-1 nit (test-reviewer-qwen): the "edited Type first" order among several affected Types is pinned by reading the code only. The script's scenario has a single affected Type. + +### Loose ends +- The eight new `TEST_AUDIT.md` rows listed under `44141d8` are open `gap`s. +- 6.3 still owes the 3 ADR toctree warnings and the check of forward references to stub pages. 6.2's page is now written, and `server.md` is next. +- The non-fatal IDN `NotImplementedError` traceback from `DummyBroadcasterInstrument` shows up in every script run. Nothing tracks it. + +### Process notes +- All six reviewers run the script on the helpers' fixed port, so the port contention seen in 6.1 got worse: in both rounds, reviewers spent long stretches in wait-and-retry loops. In round 1 the orchestrator told the five still retrying to rely on its own passing run at `da12f46`. It also rejected reviewer-glm's 200-attempt tight retry loop, which would have starved the shared port. +- A killed reviewer run left an empty `verify_broadcasts_vd8xyzcn/` directory. test-reviewer-qwen's probe showed that the script's `workspace()` does clean up when the port is taken, so this was not a script bug. test-reviewer-qwen removed the directory with the orchestrator's approval. +- The coder's inline heredoc Python probe was rejected, because the spec allows probes only as files under `orchestration/6.2/`. It redid the probe as a file. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index d3845b6..f6e2981 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -618,7 +618,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session (tabs, tints, lock column, arm strip, Locks panel, Types tab) with screenshots; using it from measurement code. Verification script `test/docs_verification/user_guide/parameter_manager.py`. Zero Sphinx warnings. -- [ ] **6.2 Technical Guide: `docs/technical_guide/broadcasts.md`.** What triggers a +- [x] **6.2 Technical Guide: `docs/technical_guide/broadcasts.md`.** What triggers a Broadcast and the wire format; `SubClient`; external forwarding; the **Broadcaster contract** (mixin, registration points, threading, no-op standalone) and the Parameter Manager's actions with their payload blueprints. Verification script under From 966f961fad0bd5e0e6abd1a7451a646c4fd05902 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 28 Sep 2026 22:42:14 -0500 Subject: [PATCH 093/107] 6.3: bookkeeping: docs-plan blocks done, audit rows, glossary and ADR final pass, ADR toctree warnings fixed, User Guide forward reference reworded --- CONTEXT.md | 6 +++--- PLAN_docs_refactor.md | 24 ++++++++++++++++-------- TEST_AUDIT.md | 6 +++++- docs/adr/0002-pull-based-locks.md | 2 +- docs/adr/0003-broadcaster-contract.md | 2 +- docs/conf.py | 2 +- docs/user_guide/parameter_manager.md | 6 ++++-- 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index a39a08d..b32da3d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ A parameter-change event published by the Server on its PUB socket for any subsc _Avoid_: notification, event stream **Broadcaster**: -The opt-in contract by which an instrument emits its own Broadcasts: it exposes `add_broadcast_sink` / `remove_broadcast_sink` / `broadcast`, and the Server registers itself as a sink when the instrument joins the Station. Instruments without it are untouched. The Parameter Manager is the first Broadcaster. It emits `pm-lock-update` (payload: a `PMLockBluePrint`) and `pm-type-update` (payload: a `PMTypeBluePrint`), and re-emits the Server's own `parameter-creation` / `parameter-deletion` for parameters it creates or removes as side effects of Type edits. +The opt-in contract by which an instrument emits its own Broadcasts: it exposes `add_broadcast_sink` / `remove_broadcast_sink` / `broadcast`, and the Server registers itself as a sink when the instrument joins the Station. Instruments without it are untouched. The Parameter Manager is the first Broadcaster. It emits `pm-lock-update` (payload: a `PMLockBluePrint`, or `None` when its Lock was removed) and `pm-type-update` (payload: a `PMTypeBluePrint`, or `None` when the Type was removed), and re-emits the Server's own `parameter-creation` for the parameters it creates as side effects of Type edits, `add_instance` and Type Lock declarations; it emits no `parameter-deletion`. _Avoid_: hook, callback, event emitter **Virtual Instrument**: @@ -77,7 +77,7 @@ A rule attached to one parameter (the **Follower**) naming another parameter, it _Avoid_: link, binding, mirror, source. Not the server's **instrument mutex**. **Target**: -The parameter a Lock points at. A parameter that is the Target of at least one locked Lock is marked as such in the tree. Deleting a Target removes the Locks that pointed at it; their Followers become plain parameters. +The parameter a Lock points at. A parameter that is the Target of at least one Lock is marked as such in the tree, its Followers counted locked and unlocked alike. Deleting a Target removes the Locks that pointed at it and clears the Type Lock rules whose stored Target it was; their Followers become plain parameters. _Avoid_: source **Follower**: @@ -88,7 +88,7 @@ A rule on a Type entry naming a Target. Declaring it puts an ordinary, locked Lo _Avoid_: group lock, rule (alone) **Globals**: -The reserved `_globals` submodule of the Parameter Manager that holds default Targets for Type Locks. It is never an Instance of anything. +The reserved `_globals` submodule of the Parameter Manager that holds the default Targets of Type Locks. Its parameters are created on demand by the Type Lock, not through `add_parameter`, which refuses the name; otherwise a Globals parameter is ordinary: it can be read and set, and it is saved with the profile. Globals is never an Instance of anything. **Instrument mutex**: The server's per-instrument `threading.RLock` that serialises concurrent `call`s to one instrument. Prose uses "instrument mutex" so it never collides with **Lock**; the code keeps its current names (`_instrument_locks`) with a rename note only. diff --git a/PLAN_docs_refactor.md b/PLAN_docs_refactor.md index 59e47da..0716cea 100644 --- a/PLAN_docs_refactor.md +++ b/PLAN_docs_refactor.md @@ -475,11 +475,16 @@ Small, mechanical, makes every later phase land cleanly. - [ ] Detachable tabs - [ ] Custom instrument widgets: mention + config hook; link to custom_widgets.md - Page: `parameter_manager.md` (verify_parameter_manager.py) - - [ ] Concept: the flagship Virtual Instrument; single source of truth - - [ ] Hierarchical parameters: add / remove / nesting - - [ ] Persistence: JSON files; profiles (refresh / switch) - - [ ] The Parameter Manager GUI + `instrumentserver-param-manager` launcher - - [ ] Using it from measurement code + - [x] Concept: the flagship Virtual Instrument; single source of truth + - [x] Hierarchical parameters: add / remove / nesting + - [x] Types: shape, Instances, Nested Types, editing and removing + - [x] Locks: the three states, chains, cycles, and what deleting a Target does + - [x] Type Locks and Globals + - [x] Profiles and files: the version-2 document, the legacy flat map, + profiles (refresh / switch) + - [x] The GUI: tabs, tints, lock column, arm strip, Locks panel, Types tab, + delete confirmation, shortcuts; + `instrumentserver-param-manager` launcher + - [x] Using it from measurement code ## Phase 4 — User Guide completion @@ -533,9 +538,12 @@ Small, mechanical, makes every later phase land cleanly. - [ ] Client side: Blueprint → dynamic proxy (methods, signatures, submodules) - [ ] Blueprint caching and invalidation - Page: `broadcasts.md` (verify_broadcasts.py) - - [ ] What triggers a Broadcast; message format - - [ ] SubClient mechanics; how GUIs stay live - - [ ] External broadcast forwarding (deep dive promised by server.md) + - [x] What triggers a Broadcast + - [x] The wire format + - [x] SubClient; how GUIs stay live + - [x] External broadcast forwarding (deep dive promised by server.md) + - [x] The Broadcaster contract + - [x] The Parameter Manager's actions - Page: `custom_widgets.md` - [ ] How `gui.type` resolves to a widget class; the widget contract - [ ] Writing and registering your own (worked example) diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 4f9bb42..981cd08 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -40,13 +40,17 @@ States: | user_guide/parameter_manager.md (future) | Types — `get_type` through a proxy | `bluePrintToDict` stringifies scalar leaves and `deserialize_obj` re-parses them numerically, so a Type entry `default` such as the string `"10"` comes back as the int `10` over the wire | Found during the plan 2.1 review; reproduced by round-tripping a `PMTypeBluePrint` through `bluePrintToDict`/`deserialize_obj` | gap | Pre-existing wire-format limitation shared with every blueprint payload (e.g. `PMLockBluePrint`, `ParameterBroadcastBluePrint.value`); not introduced by 2.1; noted per plan rule 6, not fixed here | | user_guide/parameter_manager.md (future) | Profiles — loading Globals parameters | `fromParamDict`/`fromFile` create a missing `_globals.*` parameter through the internal creation path (`_create_managed_parameter`, which the public `add_parameter` refusal of the `_globals` name bypasses), while every other missing parameter is created through the ordinary `add_parameter`; a profile file holding a `_globals.*` key creates the Globals parameter on load in both the legacy flat map and the version-2 document (create-on-load is intended for both, D18) | Found during the plan 3.1 review; reproduced with `fromParamDict({"parameter_manager._globals.x": {"unit": "s", "value": 1.0}}, deleteMissing=False)`; covered in 4.1, wording corrected in 4.2 | covered | `test_legacy_flat_file_creates_a_globals_parameter_on_load` and `test_globals_parameter_round_trips_through_the_internal_path` in `test/pytest/test_pm_persistence.py` | | user_guide/parameter_manager.md (future) | Locks — removing a parameter | `ParameterManager.remove_parameter` raises `KeyError()` when the parameter itself is missing but `ValueError` (from `_get_parent`) when an intermediate Parameter Group is missing, while `_get_param` raises `ValueError` for both; the 1.2/3.3 tests pin both types | Found during the plan 3.3 review (reviewer-qwen) | gap | Pre-existing since task 1.2; not changed per plan rule 6; if revisited, raise `ValueError` naming the full path and update the tests asserting `KeyError` | -| user_guide/parameter_manager.md (future) | Profiles — loading a file | `ParameterManager.fromFile` accepts `deleteMissing` but never forwards it to `fromParamDict`, so the GUI's `fromFile(filePath=..., deleteMissing=False)` runs with the default `True` | Found during the plan 4.1 review (reviewer-glm, the coder) | gap | Pre-existing; not changed per plan rule 6; fix is a one-line forward plus a test | +| user_guide/parameter_manager.md (future) | Profiles — loading a file | `ParameterManager.fromFile` accepts `deleteMissing` but never forwards it to `fromParamDict`, so the GUI's `fromFile(filePath=..., deleteMissing=False)` runs with the default `True` | Found during the plan 4.1 review (reviewer-glm, the coder) | gap | Product defect left open, not only a missing test: the caller's `deleteMissing` is silently ignored; pre-existing, not changed per plan rule 6; fix is a one-line forward plus a test. The docstring half is tracked in the Docstrings table (`params.ParameterManager.fromFile`) | | user_guide/parameter_manager.md (future) | Profiles — file validation | Both `schemas/parameters.json` and `schemas/parameter_manager_v2.json` use `patternProperties` without `additionalProperties: false`, so a parameter key that does not match `^(\w+)(\.\w+)*$` (e.g. with a space) passes validation and fails later in the loader, and a document listing both a parameter key and a dotted extension of it (`params.q01` and `params.q01.x`), or the bare key `params._globals`, passes validation and fails mid-load in the parameters step (a parameter cannot have child parameters) or silently shadows a submodule; inherited from the legacy reader | Found during the plan 4.1 review (plan-checker-qwen); extended during the plan 4.2 review | gap | Pre-existing in the legacy schema the plan protects; not changed per plan rule 6 | | user_guide/parameter_manager.md (future) | Types — empty Type name | `add_type("")` succeeds (only `_globals` is refused, task 2.1), so a manager can hold an empty-named Type that `toFile` writes and the version-2 reader refuses; such a manager cannot round-trip | Found during the plan 4.2 review (test-reviewer-glm) | gap | Pre-existing since 2.1; not changed per plan rule 6; fix is an empty-name refusal in `add_type` plus a test | | gui_features.md (future) | Parameter Manager GUI — live creation from another client | `ModelParameters.updateParameter`'s `parameter-creation` branch calls `instrument.update()` and then `nestedAttributeFromString` on the Proxy Instrument; a parameter another Client creates while the GUI is open raises `AttributeError` there (stale Proxy blueprint), so the row never appears | Found during the plan 5.1 work (coder probe, verified pre-existing by all six reviewers) | fixed | Fixed in plan task 5.5 by Marcos's decision (rule 6 exception): the branch resolves the element first and, on `AttributeError`, refreshes the stale Proxy blueprint and resolves again; regression tests live in `test/pytest/test_pm_gui.py` | | user_guide/parameter_manager.md (future) | Profiles — GUI start with no profile file | `ParameterManagerGui.__init__` calls `loadProfile`, which calls `switch_to_profile` with the combo's current text; with no profile file present `switch_to_profile` raises, so the GUI cannot be built until one profile exists | Found during the plan 5.1 work (coder probe) | gap | Pre-existing; not changed per plan rule 6 | | gui_features.md (future) | Parameter Manager GUI — `parameter-update` for a row with no widget | `ParameterManagerTreeView.onItemNewValue` indexes `self.delegate.parameters[itemName]` without a guard, so a `parameter-update` Broadcast for a row whose editor widget was never created raises `KeyError` and the value never shows | Found during the plan 5.3 round-0 review (reviewer-qwen) | gap | Pre-existing; not changed per plan rule 6; the new `ParameterManagerGui._on_item_new_value` guards with `.get` and logs instead of raising | | user_guide/parameter_manager.md | Type Locks and Globals | On a Proxy Instrument, `pm.get`/`pm.set` resolve to QCoDeS' local deprecated `InstrumentBase.get`/`set` and raise `KeyError` on a dotted path, so the working route to a Globals parameter is `Client.call("parameter_manager.set", ...)`; attribute access reaches the Globals submodule on a fresh Proxy or after `update()`, while a Proxy built before the parameter existed raises `AttributeError` until then | `section_type_locks_and_globals` in `verify_parameter_manager.py` | covered | The `Client.call` route is pinned over the wire in `test_pm_types.py` (the Globals-Target proxy tests, whose comments note the shadowing); the `AttributeError`-until-`update()` behaviour has no direct pytest | +| user_guide/parameter_manager.md; technical_guide/broadcasts.md | Types; The Parameter Manager's actions | `parameter-creation` Broadcasts for the parameters the Type-editing methods and `add_instance` create as side effects, one per created parameter in creation order; `add_nested_type` belongs to that list too, which the Broadcasts page initially left out | `section_the_parameter_managers_actions` in verify_broadcasts.py, extended to `add_nested_type` and three further Type-editing methods in the 6.2 fix round | covered | Pinned in `test/pytest/test_pm_types.py`: `test_add_type_parameter_emits_the_creation_then_the_type_updates`, `test_add_nested_type_emits_the_creation_then_the_type_updates`, `test_add_instance_emits_one_creation_per_created_parameter`, `test_ensure_global_target_emits_one_creation_and_is_idempotent`, `test_lock_type_parameter_emits_creation_lock_updates_then_type_update`, `test_add_instance_with_a_type_lock_emits_creations_then_lock_updates`, and over the wire `test_subclient_sees_pm_type_update_and_creations_from_a_second_client` | +| technical_guide/broadcasts.md | What triggers a Broadcast | The Server emits every Broadcast from the worker thread that executed the client request, while that worker holds the instrument's instrument mutex. The script pins the worker-thread half and that a second request for the same instrument serializes behind the mutex, but nothing observes the emission happening inside the held critical section | `section_what_triggers_a_broadcast` in verify_broadcasts.py (partial) | waived | Dropped by the 6.2 review as hard to observe cleanly; the emission point follows from the code path (`_callObject` runs the call, and with it the instrument's `broadcast` and the Server's own emissions, inside `with lock:` on the per-instrument mutex). No pytest covers it | +| technical_guide/broadcasts.md | The Broadcaster contract | The Server knows the contract by shape, not by base class: registration checks `hasattr(instrument, "add_broadcast_sink")`, so any duck-typed instrument carrying that attribute would get the sink; every tested instrument is a `Broadcaster` subclass, so nothing tells the shape check and an isinstance check apart | `section_the_broadcaster_contract` in verify_broadcasts.py | gap | No duck-typed instrument class exists in `instrumentserver.testing` to test with; a dummy exposing `add_broadcast_sink`/`broadcast` without the `Broadcaster` base would pin the shape check | +| user_guide/parameter_manager.md | Profiles and files | `switch_to_profile` refuses an unknown profile, but `does_profile_exist` matches by substring: a name that is a substring of an existing profile file passes the check, and the switch then saves, clears and loads a missing file, which `fromFile` only warns about, so the Parameter Manager ends up empty | Found during the plan 4.3 review (reviewer-qwen); sent to TEST_AUDIT at 6.3 | gap | Pre-existing product defect; not changed per plan rule 6; fix is an exact-match profile check plus a test | ## Manual checks diff --git a/docs/adr/0002-pull-based-locks.md b/docs/adr/0002-pull-based-locks.md index 56ef9dd..9708fce 100644 --- a/docs/adr/0002-pull-based-locks.md +++ b/docs/adr/0002-pull-based-locks.md @@ -17,5 +17,5 @@ A locked Follower answers `get` by asking its Target and returning that value; ` - A Follower keeps its own underlying value while locked. Unlocking exposes that own value again (deliberate: "unlock and see its own value" is the point). Profiles store the own value plus the Lock. - QCoDeS reads the cache, not `get`, for snapshots with `update=False`. `ManagedParameter` therefore reports the Target's value in its snapshot while locked, and the profile writer reads the own value explicitly. Otherwise measurement metadata would record stale values. - GUIs repaint Followers when they receive a `parameter-update` for the Target; the Parameter Manager emits nothing for values. -- Deleting a Target removes the Locks pointing at it; their Followers become plain parameters. +- Deleting a Target removes the Locks pointing at it; their Followers become plain parameters. When the deleted Target was the stored Target of Type Locks, those rules are cleared with it. - Targets are restricted to parameters inside the same Parameter Manager. diff --git a/docs/adr/0003-broadcaster-contract.md b/docs/adr/0003-broadcaster-contract.md index c9266ac..9ef46e0 100644 --- a/docs/adr/0003-broadcaster-contract.md +++ b/docs/adr/0003-broadcaster-contract.md @@ -18,6 +18,6 @@ The Server only broadcasts what it can see: parameter sets it executed, and call ## Consequences - Messages use the existing `ParameterBroadcastBluePrint`, same socket, same topic (instrument name), same wire format. Existing subscribers parse them unchanged. -- The Parameter Manager emits `pm-lock-update` (`PMLockBluePrint` payload), `pm-type-update` (`PMTypeBluePrint` payload), and re-emits `parameter-creation` / `parameter-deletion` for parameters it creates or removes as side effects. Direct `add_parameter` / `remove_parameter` calls keep being announced by the Server, so nothing is announced twice. +- The Parameter Manager emits `pm-lock-update` (`PMLockBluePrint` payload, `None` when a Lock was removed), `pm-type-update` (`PMTypeBluePrint` payload, `None` when a Type was removed), and re-emits `parameter-creation` for the parameters its Type edits, `add_instance` and Type Lock declarations create as side effects. It emits no `parameter-deletion`: only a direct `remove_parameter` call is announced, by the Server; the parameter removals of a profile load and of `remove_all_parameters` re-emit no creation or deletion Broadcasts. Direct `add_parameter` / `remove_parameter` calls keep being announced by the Server, so nothing is announced twice. - `broadcast` runs on the thread that called the instrument method, which for a client request is the worker thread holding the instrument mutex: the same place the Server's own broadcasts already run. - The `_instrument_locks` code in the Server is not renamed or altered; a comment notes that prose calls it the "instrument mutex". diff --git a/docs/conf.py b/docs/conf.py index f3c0b9a..da7aa5d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -86,7 +86,7 @@ } templates_path = ['_templates'] -exclude_patterns = ['build', 'agents', 'README.md', 'Thumbs.db', '.DS_Store', '**.ipynb_checkpoints'] +exclude_patterns = ['build', 'agents', 'adr', 'README.md', 'Thumbs.db', '.DS_Store', '**.ipynb_checkpoints'] # -- Internationalization ---------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#internationalization diff --git a/docs/user_guide/parameter_manager.md b/docs/user_guide/parameter_manager.md index 721c8d7..0dad591 100644 --- a/docs/user_guide/parameter_manager.md +++ b/docs/user_guide/parameter_manager.md @@ -879,5 +879,7 @@ where this one ended: ``` The [Python Client](client.md) page covers the Client's connection lifecycle -and error handling, and [the Server](server.md) explains where profile files -end up when the Server runs somewhere else. +and error handling. The profile files end up in the working directory of the +Server process, as Profiles and files above describes, so where they live +follows from where the Server runs; [the Server](server.md) page documents +the Server itself. From d59ad5f863375e660c441fe3bff7fc8c48500095 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 10:51:40 -0500 Subject: [PATCH 094/107] 6.3: fix from review round 1: Globals profile-load creation route, two audit rows for the 1.2/1.3 loose ends, broadcasts bullet renamed to its section title --- CONTEXT.md | 2 +- PLAN_docs_refactor.md | 2 +- TEST_AUDIT.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index b32da3d..fc18479 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,7 +88,7 @@ A rule on a Type entry naming a Target. Declaring it puts an ordinary, locked Lo _Avoid_: group lock, rule (alone) **Globals**: -The reserved `_globals` submodule of the Parameter Manager that holds the default Targets of Type Locks. Its parameters are created on demand by the Type Lock, not through `add_parameter`, which refuses the name; otherwise a Globals parameter is ordinary: it can be read and set, and it is saved with the profile. Globals is never an Instance of anything. +The reserved `_globals` submodule of the Parameter Manager that holds the default Targets of Type Locks. Its parameters are created on demand by a Type Lock or by a profile load that lists one, never through `add_parameter`, which refuses the name; otherwise a Globals parameter is ordinary: it can be read and set, and it is saved with the profile. Globals is never an Instance of anything. **Instrument mutex**: The server's per-instrument `threading.RLock` that serialises concurrent `call`s to one instrument. Prose uses "instrument mutex" so it never collides with **Lock**; the code keeps its current names (`_instrument_locks`) with a rename note only. diff --git a/PLAN_docs_refactor.md b/PLAN_docs_refactor.md index 0716cea..29b77e4 100644 --- a/PLAN_docs_refactor.md +++ b/PLAN_docs_refactor.md @@ -541,7 +541,7 @@ Small, mechanical, makes every later phase land cleanly. - [x] What triggers a Broadcast - [x] The wire format - [x] SubClient; how GUIs stay live - - [x] External broadcast forwarding (deep dive promised by server.md) + - [x] External forwarding (deep dive promised by server.md) - [x] The Broadcaster contract - [x] The Parameter Manager's actions - Page: `custom_widgets.md` diff --git a/TEST_AUDIT.md b/TEST_AUDIT.md index 981cd08..349f6ae 100644 --- a/TEST_AUDIT.md +++ b/TEST_AUDIT.md @@ -51,6 +51,8 @@ States: | technical_guide/broadcasts.md | What triggers a Broadcast | The Server emits every Broadcast from the worker thread that executed the client request, while that worker holds the instrument's instrument mutex. The script pins the worker-thread half and that a second request for the same instrument serializes behind the mutex, but nothing observes the emission happening inside the held critical section | `section_what_triggers_a_broadcast` in verify_broadcasts.py (partial) | waived | Dropped by the 6.2 review as hard to observe cleanly; the emission point follows from the code path (`_callObject` runs the call, and with it the instrument's `broadcast` and the Server's own emissions, inside `with lock:` on the per-instrument mutex). No pytest covers it | | technical_guide/broadcasts.md | The Broadcaster contract | The Server knows the contract by shape, not by base class: registration checks `hasattr(instrument, "add_broadcast_sink")`, so any duck-typed instrument carrying that attribute would get the sink; every tested instrument is a `Broadcaster` subclass, so nothing tells the shape check and an isinstance check apart | `section_the_broadcaster_contract` in verify_broadcasts.py | gap | No duck-typed instrument class exists in `instrumentserver.testing` to test with; a dummy exposing `add_broadcast_sink`/`broadcast` without the `Broadcaster` base would pin the shape check | | user_guide/parameter_manager.md | Profiles and files | `switch_to_profile` refuses an unknown profile, but `does_profile_exist` matches by substring: a name that is a substring of an existing profile file passes the check, and the switch then saves, clears and loads a missing file, which `fromFile` only warns about, so the Parameter Manager ends up empty | Found during the plan 4.3 review (reviewer-qwen); sent to TEST_AUDIT at 6.3 | gap | Pre-existing product defect; not changed per plan rule 6; fix is an exact-match profile check plus a test | +| user_guide/parameter_manager.md | Locks | A dict-valued response such as `list_locks()` (a mapping of Follower paths to `PMLockBluePrint`s) travels over the wire as `str(dict)` (`ServerResponse.toJson` sends a top-level dict message that way), and `ServerResponse.__init__` rebuilds it by quote and bracket substitution, so a value containing a quote does not round-trip | The page's `list_locks` example (`section_locks` in verify_parameter_manager.py); found during the plan 1.3 review (reviewer-qwen) | gap | Pre-existing; unchanged per plan rule 6; only the happy path is pinned (`test_get_lock_and_list_locks_deserialise_to_pm_lock_blueprint`); it works for lab paths and would break if a value ever contained a quote | +| user_guide/parameter_manager.md (future) | Hierarchical parameters | A Parameter Group attached by foreign code through a direct `add_submodule` keeps `_root = None`, so its `add_parameter`/`remove_parameter` stay local and unrouted (no `ManagedParameter` creation, no Lock cleanup); a plain `Parameter` on a Parameter Group cannot be a Lock Follower (`lock` raises naming the path) but remains a legal Target | Found during the plan 1.2 review (reviewer-glm); orchestrator note for TEST_AUDIT/1.3 | gap | Unreachable over the wire (every Parameter Group the Parameter Manager creates gets `_root` set); the plain-Parameter halves are pinned by `test_lock_with_a_plain_group_parameter`, the foreign-`add_submodule` state has no test; pre-existing, unchanged per plan rule 6 | ## Manual checks From 7b7c1488139c0337768fd6224372fb55dd553fe5 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 11:15:05 -0500 Subject: [PATCH 095/107] 6.3: history Co-Authored-By: Claude Fable 5.1 --- HISTORY_parameter_manager_redesign.md | 45 +++++++++++++++++++++++++++ PLAN_parameter_manager_redesign.md | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/HISTORY_parameter_manager_redesign.md b/HISTORY_parameter_manager_redesign.md index 00a8554..7c6c73e 100644 --- a/HISTORY_parameter_manager_redesign.md +++ b/HISTORY_parameter_manager_redesign.md @@ -1074,3 +1074,48 @@ The stub `docs/technical_guide/broadcasts.md` is now the full Technical Guide pa - All six reviewers run the script on the helpers' fixed port, so the port contention seen in 6.1 got worse: in both rounds, reviewers spent long stretches in wait-and-retry loops. In round 1 the orchestrator told the five still retrying to rely on its own passing run at `da12f46`. It also rejected reviewer-glm's 200-attempt tight retry loop, which would have starved the shared port. - A killed reviewer run left an empty `verify_broadcasts_vd8xyzcn/` directory. test-reviewer-qwen's probe showed that the script's `workspace()` does clean up when the port is taken, so this was not a script bug. test-reviewer-qwen removed the directory with the orchestrator's approval. - The coder's inline heredoc Python probe was rejected, because the spec allows probes only as files under `orchestration/6.2/`. It redid the probe as a file. + +## 6.3 Bookkeeping — 2026-09-29 + +This task closes the plan's paperwork. `PLAN_docs_refactor.md` now lists the sections the two new pages actually have, all done. `TEST_AUDIT.md` gained six tests rows for gaps and defects that earlier tasks had left without one. `CONTEXT.md` and ADRs 0002 and 0003 were corrected where they no longer matched the shipped behaviour. Two leftovers from 6.1 were also closed: `'adr'` joins `exclude_patterns` in `docs/conf.py`, so the three ADR toctree warnings are gone and the docs build has zero warnings, and the User Guide's forward reference to `server.md` was reworded. No source code, tests or verification scripts changed. + +### Commit by commit +- `966f961` The bookkeeping, one commit across seven files. The orchestrator's coder spec gave seven readings. The main ones: + - Reading 1: in the docs plan, replace each page block's bullets with its level-2 sections, all `[x]`. `parameter_manager.md` now has eight bullets and `broadcasts.md` six. The docs plan keeps no record of finished pages beyond the checkboxes. + - Reading 2: add `TEST_AUDIT.md` rows only for gaps that have no row yet, and edit an existing row only if its state is wrong. Four rows were added: + - `covered`: the side-effect `parameter-creation`s, `add_nested_type` included, pinned by seven tests in `test_pm_types.py`. + - `waived`: the claim that a Broadcast is emitted while the instrument mutex is held, which 6.2 dropped. + - `gap`: `hasattr` versus `isinstance` registration, since there is no duck-typed instrument to test with. + - `gap`: the 4.3 loose end that `does_profile_exist` matches by substring. + + The "Profiles — loading a file" row keeps its `gap` state, and its notes now open by calling it a product defect left open: `fromFile` ignores `deleteMissing`. The 5.5 creation-branch row already read `fixed`. The stale-Proxy `AttributeError` was already in the notes of the Type Locks and Globals row, so it got no new row. + - Reading 3: the glossary may get wording fixes only. Three entries changed: + - Broadcaster: the `None` payloads, and "it emits no `parameter-deletion`". The old text said the Parameter Manager re-emits deletions, but `params.py` never emits one. That makes D22's deletion clause empty: nothing removes parameters as a side effect. + - Target: the tree marks Targets and counts their Followers whether the Locks are locked or unlocked, and deleting a Target also clears Type Lock rules. + - Globals: `add_parameter` refuses the name, and a Globals parameter can otherwise be read, set and saved like any other. + - Reading 4: an ADR may change in its Consequences only. ADR-0002 gained the Type Lock clearing on Target deletion. ADR-0003's second bullet was rewritten: no `parameter-deletion`, and no creation or deletion Broadcasts from a profile load or from `remove_all_parameters`. ADR-0001 was checked and left unchanged. Before review, the orchestrator confirmed in `params.py` that `_broadcast_parameter_creation` is called from exactly four places. + - Reading 6: "the Technical Guide page above lists them all" is true now that `broadcasts.md` is written. The `server.md` sentence was reworded to say that profile files end up in the Server process's working directory, which the page already states, and to link `server.md` only as the Server's page. + + The coder left the 1.3 dict-response loose end without a row, arguing that 1.3's `BluePrintType` branch had superseded it. It also flagged the `does_profile_exist` row as a judgment call. Orchestrator run: ruff clean, both scripts all sections OK, docs build with zero warnings, 543 in the full suite. +- `d59ad5f` Fix from round 0, four items, in three files: + - The Globals entry now names the second way its parameters get created: "by a Type Lock or by a profile load that lists one" (`fromParamDict` calls `_create_managed_parameter`). test-reviewer-glm raised it as should-fix and reviewer-glm as a nit. + - New `gap` row for the 1.3 loose end. `ServerResponse.toJson` still sends a top-level dict as `str(dict)` (`blueprints.py` :770-773), so a value containing a quote does not survive the trip, and `list_locks` is the documented case. Only the happy path is pinned. The commit shows the coder's "superseded" reading was wrong: 1.3's branch fixes Blueprint values inside the dict, not the top-level `str()`. Raised by both test reviewers (should-fix). + - New `gap` row for the 1.2 loose end. A Parameter Group that foreign code attaches through `add_submodule` keeps `_root = None` and skips routing. A plain `Parameter` can be a Target but not a Follower. Raised by test-reviewer-qwen (should-fix). + - A bullet in the docs plan was renamed to "External forwarding", to match the page's section title (plan-checker-qwen, a nit sent because a fix round was happening anyway). + + All six approved in round 1. Orchestrator run: ruff clean, both scripts OK, zero build warnings, 543 in the full suite. + +### Dropped findings +- The Target entry's "marked as such in the tree" leaves out the GUI rule that a row that is both Follower and Target shows its Follower text (reviewer-qwen, plan-checker-qwen). Not sent: the wording predates this task, and the User Guide page documents the rule. +- The Claiming Type entry leaves out the code's final tie-break by Type name. The coder left it alone because neither page documents it. +- Round 1 (reviewer-qwen, test-reviewer-glm): the new "Hierarchical parameters" row's Page column reads `user_guide/parameter_manager.md (future)`, although the page exists. The marker was copied from the fix list, and older rows carry it too. Not sent, and flagged for Marcos as a one-word cleanup. + +### Loose ends +- Four of the six new `TEST_AUDIT.md` rows are open `gap`s. The other two are the `covered` and `waived` rows. `fromFile` ignoring `deleteMissing` and the `does_profile_exist` substring match are product defects that were left unfixed. +- The `(future)` markers in the `TEST_AUDIT.md` Page column are now inconsistent and could be cleaned up in one pass. +- `server.md` is still a stub. The User Guide no longer promises anything specific from it. + +### Process notes +- Two reviewer docs-build requests were rejected. plan-checker-glm aimed at the same build folder as reviewer-glm, and reviewer-qwen used cwd-relative paths that would have landed outside `round-0/`. Both retried with their own explicit paths. +- plan-checker-qwen created a stray `docs/orchestration/6.3/round-0` directory by mistake and removed it with approval. +- The port contention on 5555 continued: several reviewers ran the User Guide script in sleep-and-retry loops in both rounds. diff --git a/PLAN_parameter_manager_redesign.md b/PLAN_parameter_manager_redesign.md index f6e2981..1843940 100644 --- a/PLAN_parameter_manager_redesign.md +++ b/PLAN_parameter_manager_redesign.md @@ -623,7 +623,7 @@ Each task: what to build, files touched, acceptance, tests. One task per session contract** (mixin, registration points, threading, no-op standalone) and the Parameter Manager's actions with their payload blueprints. Verification script under `test/docs_verification/technical_guide/`. Cross-link with the User Guide page. -- [ ] **6.3 Bookkeeping.** Update `PLAN_docs_refactor.md` (mark the two pages done, adjust +- [x] **6.3 Bookkeeping.** Update `PLAN_docs_refactor.md` (mark the two pages done, adjust their section lists to what was written), `TEST_AUDIT.md` (gaps noticed, defects left), and do a final pass of `CONTEXT.md` and the three ADRs against the shipped behaviour. From 8276361f6e84bf11ebb1d74fa3b6459a8cf6edfd Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 14:40:29 -0500 Subject: [PATCH 096/107] Move the Parameter Manager GUI out of gui/instruments.py gui/instruments.py is back to the generic parameter and method display and GenericInstrument. The Parameter Manager GUI now lives in gui/parameter_manager/: logic.py (no widgets: claims, tints, Lock and Types rows, PMState), panels.py (gutter delegate, arm strip, Locks panel, Types tab) and widget.py (ParameterManagerGui, its model, tree view, delete delegate, create form and profiles box). The code is moved line for line; only imports, section banners and six cross-module docstring references changed. instruments.py keeps a lazy module __getattr__ so station configs naming instrumentserver.gui.instruments.ParameterManagerGui (or PMState) keep loading. In-repo configs, docs and tests use the new path. The lockChanged/typeChanged signals and the pm-lock-update/pm-type-update branches move from the generic ModelParameters to ModelParameterManager; a plain ModelParameters now ignores those Broadcasts. Co-Authored-By: Claude Opus 5.5 (1M context) --- TODO_type_cleanup.md | 5 + docs/getting_started/overview.md | 2 +- docs/user_guide/parameter_manager.md | 6 +- docs/user_guide/serverConfig.yml | 2 +- src/instrumentserver/apps.py | 2 +- src/instrumentserver/gui/instruments.py | 3640 +---------------- .../gui/parameter_manager/__init__.py | 66 + .../gui/parameter_manager/logic.py | 805 ++++ .../gui/parameter_manager/panels.py | 1414 +++++++ .../gui/parameter_manager/widget.py | 1522 +++++++ .../technical_guide/verify_broadcasts.py | 2 +- .../Prototype the ParamManager.ipynb | 2 +- test/prototyping/testing_parameter_manager.py | 2 +- test/pytest/test_pm_gui.py | 73 +- test/test_async_requests/serverConfig.yml | 2 +- 15 files changed, 3910 insertions(+), 3635 deletions(-) create mode 100644 src/instrumentserver/gui/parameter_manager/__init__.py create mode 100644 src/instrumentserver/gui/parameter_manager/logic.py create mode 100644 src/instrumentserver/gui/parameter_manager/panels.py create mode 100644 src/instrumentserver/gui/parameter_manager/widget.py diff --git a/TODO_type_cleanup.md b/TODO_type_cleanup.md index d4a5322..4058b0b 100644 --- a/TODO_type_cleanup.md +++ b/TODO_type_cleanup.md @@ -1,5 +1,10 @@ # Deferred type-cleanup: `InstrumentModelBase` parent/filter types +> Note (2026-09-29): the Parameter Manager GUI has since moved out of +> `gui/instruments.py` into `gui/parameter_manager/`, so the +> `gui/instruments.py` line numbers below are out of date. Search for the +> named symbols instead. + During the mypy sweep, roughly 10 `# type: ignore` comments were concentrated around `InstrumentModelBase.addItem` / `insertItemTo` / `fillCollapsedDict` in `src/instrumentserver/gui/base_instrument.py`. They are all symptoms of two diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md index f1df070..95d0fcd 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -203,7 +203,7 @@ instruments: type: instrumentserver.params.ParameterManager initialize: True gui: - type: instrumentserver.gui.instruments.ParameterManagerGui + type: instrumentserver.gui.parameter_manager.ParameterManagerGui ``` ![parameter_manager_2](../_static/param_manager_2.png) diff --git a/docs/user_guide/parameter_manager.md b/docs/user_guide/parameter_manager.md index 0dad591..e7f5c6e 100644 --- a/docs/user_guide/parameter_manager.md +++ b/docs/user_guide/parameter_manager.md @@ -614,9 +614,11 @@ Parameter Manager named `parameter_manager` if it does not exist yet, and opens the window. `--name` chooses a different Parameter Manager. The Server window itself opens the generic instrument widget for a Parameter Manager unless the station config's `gui` entry names -`instrumentserver.gui.instruments.ParameterManagerGui`, as the +`instrumentserver.gui.parameter_manager.ParameterManagerGui`, as the `serverConfig.yml` in the repository does; then the Server window embeds the -same widget, and the launcher above is the sure way to get it. +same widget, and the launcher above is the sure way to get it. Configs that +still name the older path `instrumentserver.gui.instruments.ParameterManagerGui` +keep working. [the Server](server.md) covers launching and the station config, and [GUI features](gui_features.md) describes the `gui` entry and the patterns shared by every instrument window: starring, trashing, filtering, and the diff --git a/docs/user_guide/serverConfig.yml b/docs/user_guide/serverConfig.yml index a444dd2..1bc3d6c 100644 --- a/docs/user_guide/serverConfig.yml +++ b/docs/user_guide/serverConfig.yml @@ -83,7 +83,7 @@ instruments: gui: # By having the parameter manager GUI as the gui type, we can have it directly in the server instead of on a separate window. - type: instrumentserver.gui.instruments.ParameterManagerGui + type: instrumentserver.gui.parameter_manager.ParameterManagerGui # GUI defaults: Class-based configuration that applies to all instances of a given instrument class # These are merged with instance-specific gui.kwargs configs (instance settings take precedence) diff --git a/src/instrumentserver/apps.py b/src/instrumentserver/apps.py index f0e05f4..8922272 100644 --- a/src/instrumentserver/apps.py +++ b/src/instrumentserver/apps.py @@ -12,7 +12,7 @@ from .client.application import ClientStationGui from .config import loadConfig from .gui import widgetMainWindow -from .gui.instruments import ParameterManagerGui +from .gui.parameter_manager import ParameterManagerGui from .log import setupLogging from .server.application import startServerGuiApplication from .server.core import startServer diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 23ffafd..90238e1 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1,16 +1,12 @@ -import ast import inspect import logging -from dataclasses import dataclass from typing import ( + TYPE_CHECKING, Any, Callable, Dict, - Iterable, - List, - Mapping, Optional, - Tuple, + Type, Union, cast, ) @@ -25,16 +21,10 @@ PARAMETER_CREATION, PARAMETER_DELETION, PARAMETER_UPDATE, - PM_LOCK_UPDATE, - PM_TYPE_UPDATE, ParameterBroadcastBluePrint, - PMLockBluePrint, - PMTypeBluePrint, ) from ..client import ProxyInstrument, SubClient from ..helpers import nestedAttributeFromString -from ..params import ParameterManager, ParameterTypes, parameterTypes, paramTypeFromName -from . import keepSmallHorizontally from .base_instrument import ( DelegateBase, InstrumentDisplayBase, @@ -44,177 +34,30 @@ ) from .parameters import AnyInput, AnyInputForMethod, ParameterWidget +if TYPE_CHECKING: + from .parameter_manager import ParameterManagerGui, PMState + # TODO: all styles set through a global style sheet. # TODO: [maybe] add a column for information on valid input values? logger = logging.getLogger(__name__) +#: Names that moved to :mod:`instrumentserver.gui.parameter_manager` and are +#: still served from this module, so station configs that name +#: ``instrumentserver.gui.instruments.ParameterManagerGui`` keep loading. +_MOVED_TO_PARAMETER_MANAGER = ("ParameterManagerGui", "PMState") -class AddParameterWidget(QtWidgets.QWidget): - """A widget that allows parameter creation. - - :param parent: parent widget - :param typeInput: if ``True``, add input fields for creating a value - validator. - """ - - #: Signal(str, str, str, ParameterTypes, str) - newParamRequested = QtCore.Signal(str, str, str, ParameterTypes, str) - - #: Signal(str) - invalidParamRequested = QtCore.Signal(str) - - def __init__( - self, parent: Optional[QtWidgets.QWidget] = None, typeInput: bool = False - ) -> None: - super().__init__(parent) - - self.typeInput = typeInput - - layout = QtWidgets.QGridLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self.nameEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Name:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 0, 0) - layout.addWidget(self.nameEdit, 0, 1) - - self.valueEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Value:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 0, 2) - layout.addWidget(self.valueEdit, 0, 3) - - self.unitEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Unit:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 0, 4) - layout.addWidget(self.unitEdit, 0, 5) - - if typeInput: - self.typeSelect = QtWidgets.QComboBox(self) - names: list[str] = [] - for t, v in parameterTypes.items(): - names.append(str(v["name"])) - for n in sorted(names): - self.typeSelect.addItem(n) - self.typeSelect.setCurrentText( - str(parameterTypes[ParameterTypes.numeric]["name"]) - ) - lbl = QtWidgets.QLabel("Type:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 1, 0) - layout.addWidget(self.typeSelect, 1, 1) - - self.valsArgsEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Type opts.:") - lbl.setToolTip( - "Optional, for constraining parameter values." - "Allowed args and defaults:\n" - " - 'Numeric': min_value=-1e18, max_value=1e18\n" - " - 'Integer': min_value=-inf, max_value=inf\n" - " - 'String': min_length=0, max_length=1e9\n" - "See qcodes.utils.validators for details." - ) - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 1, 2) - layout.addWidget(self.valsArgsEdit, 1, 3) - - self.addButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/plus-square.svg"), " Add", parent=self - ) - self.addButton.clicked.connect(self.requestNewParameter) - self.nameEdit.returnPressed.connect(self.addButton.click) - self.valueEdit.returnPressed.connect(self.addButton.click) - self.unitEdit.returnPressed.connect(self.addButton.click) - layout.addWidget(self.addButton, 0, 6, 1, 1) - self.addButton.setAutoDefault(True) +def __getattr__(name: str) -> Union[Type["ParameterManagerGui"], Type["PMState"]]: + # imported on first use: the parameter_manager package imports this module + if name in _MOVED_TO_PARAMETER_MANAGER: + from . import parameter_manager - self.clearButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/delete.svg"), " Clear", parent=self + return cast( + Union[Type["ParameterManagerGui"], Type["PMState"]], + getattr(parameter_manager, name), ) - - self.clearButton.setAutoDefault(True) - self.clearButton.clicked.connect(self.clear) - layout.addWidget(self.clearButton, 0, 7, 1, 1) - - self.setLayout(layout) - self.invalidParamRequested.connect(self.setError) - - @QtCore.Slot() - def clear(self) -> None: - self.clearError() - self.nameEdit.setText("") - self.valueEdit.setText("") - self.unitEdit.setText("") - if self.typeInput: - self.typeSelect.setCurrentText( - parameterTypes[ParameterTypes.numeric]["name"] # type: ignore[arg-type] - ) - self.valsArgsEdit.setText("") - - @QtCore.Slot(bool) - def requestNewParameter(self, _: bool) -> None: - self.clearError() - - name = self.nameEdit.text().strip() - if len(name) == 0: - self.invalidParamRequested.emit("Name must not be empty.") - return - value = self.valueEdit.text() - unit = self.unitEdit.text() - - if hasattr(self, "typeSelect"): - ptype = paramTypeFromName(self.typeSelect.currentText()) - valsArgs = self.valsArgsEdit.text() - else: - ptype = ParameterTypes.any - valsArgs = "" - - self.newParamRequested.emit(name, value, unit, ptype, valsArgs) - - @QtCore.Slot(str) - def setError(self, message: str) -> None: - self.addButton.setStyleSheet(""" - QPushButton { background-color: red } - """) - self.addButton.setToolTip(message) - - def clearError(self) -> None: - self.addButton.setStyleSheet("") - self.addButton.setToolTip("") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") class MethodDisplay(QtWidgets.QWidget): @@ -431,18 +274,6 @@ class ModelParameters(InstrumentModelBase): #: name, second object is its new value itemNewValue = QtCore.Signal(object, object) - #: Signal(str, object) -- - #: Emitted on a ``pm-lock-update`` Broadcast: the Follower's path relative - #: to the instrument, and its :class:`PMLockBluePrint` (``None`` when its - #: Lock was removed). No model item is touched for this action. - lockChanged = QtCore.Signal(str, object) - - #: Signal(str, object) -- - #: Emitted on a ``pm-type-update`` Broadcast: the Type's name (the part - #: after the instrument name), and its :class:`PMTypeBluePrint` (``None`` - #: when the Type was removed). No model item is touched for this action. - typeChanged = QtCore.Signal(str, object) - def __init__(self, *args: Any, **kwargs: Any) -> None: # make sure we pass the server ip and port properly to the subscriber when the values are not defaults. subClientArgs = { @@ -534,15 +365,6 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: # The model can't actually modify the widget since it knows nothing about the view itself. self.itemNewValue.emit(item[0].name, bp.value) - elif bp.action == PM_LOCK_UPDATE: - # Locks and Types claim no model item of their own: the Lock - # column and the Type tints are separate tasks. The Parameter - # Manager GUI records the change in its PMState (D10). - self.lockChanged.emit(fullName, bp.value) - - elif bp.action == PM_TYPE_UPDATE: - self.typeChanged.emit(fullName, bp.value) - def insertItemTo( self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem ) -> None: @@ -565,89 +387,6 @@ def insertItemTo( self.newItem.emit(item) -class ModelParameterManager(ModelParameters): - #: Signal() -- - #: Emitted after a Broadcast changed the tree's structure: a parameter - #: was created or removed, or a ``parameter-update``/``parameter-call`` - #: added a row the model did not know. The Parameter Manager GUI - #: recomputes the Type claims that the tints and gutter bands show. - structureChanged = QtCore.Signal() - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - # ModelParameters pins the column count at 3 after loading; widen it - # again and give every loaded row the gutter item the narrow count - # dropped, and the Lock column item (plan task 5.3) - self.setColumnCount(LOCK_COLUMN + 1) - self.setHorizontalHeaderLabels([self.attr, "unit", "", "", "locked to"]) - self._ensure_extra_items(self.invisibleRootItem()) - - def _ensure_extra_items(self, parent: QtGui.QStandardItem) -> None: - """Give every row under ``parent`` its gutter item and its Lock - column item.""" - for row in range(parent.rowCount()): - for column in (GUTTER_COLUMN, LOCK_COLUMN): - if parent.child(row, column) is None: - parent.setChild(row, column, QtGui.QStandardItem()) - item = parent.child(row, 0) - if item is not None and item.hasChildren(): - self._ensure_extra_items(item) - - def insertItemTo( - self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem - ) -> None: - if item is not None: - # A parameter might not have a unit - unit = "" - if item.element is not None: # type: ignore[attr-defined] - unit = item.element.unit # type: ignore[attr-defined] - unitItem = QtGui.QStandardItem(unit) - extraItem = QtGui.QStandardItem() - gutterItem = QtGui.QStandardItem() - lockItem = QtGui.QStandardItem() - - if parent == self: - rowCount = self.rowCount() - self.setItem(rowCount, 0, item) - self.setItem(rowCount, 1, unitItem) - self.setItem(rowCount, 2, extraItem) - self.setItem(rowCount, GUTTER_COLUMN, gutterItem) - self.setItem(rowCount, LOCK_COLUMN, lockItem) - else: - parent.appendRow([item, unitItem, extraItem, gutterItem, lockItem]) - - self.newItem.emit(item) - - def _has_row(self, full_name: str) -> bool: - """Whether the model holds a row for the dotted path ``full_name`` - (the Broadcast name with the instrument name stripped).""" - return bool( - self.findItems( - full_name, - cast( - "QtCore.Qt.MatchFlags", - QtCore.Qt.MatchFlag.MatchExactly - | QtCore.Qt.MatchFlag.MatchRecursive, - ), - 0, - ) - ) - - def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: - fullName = ".".join(bp.name.split(".")[1:]) - value_update = bp.action in (PARAMETER_UPDATE, PARAMETER_CALL) - known_row = value_update and self._has_row(fullName) - super().updateParameter(bp) - # a parameter-update or parameter-call for a row the model did not - # know adds one through the base update branch; matching depends - # on which parameters exist, so the tints and gutter bands must be - # recomputed for it too (plan task 5.6), or the new row would - # stay untinted until the next recompute - added_row = value_update and not known_row and self._has_row(fullName) - if bp.action in (PARAMETER_CREATION, PARAMETER_DELETION) or added_row: - self.structureChanged.emit() - - class ParametersTreeView(InstrumentTreeViewBase): def __init__( self, @@ -780,3351 +519,6 @@ def _clearCurrentParameter(self) -> None: # ----------------- Parameters Display Classes - Ending -------------------------------- -# ----------------- Parameters Manager Classes - Beginning ----------------------------- - - -# ----------------- Parameter Manager tints - Beginning -------------------------------- - - -#: Logical index of the gutter column of :class:`ModelParameterManager`, -#: whose items carry a row's stack of Types for the -#: :class:`GutterDelegate` to draw. The existing columns keep their -#: indexes: name (0), unit (1), delegate (2). -GUTTER_COLUMN = 3 - -#: Fixed pixel width of the gutter column in the view. -GUTTER_WIDTH = 12 - -#: Data role under which a row's stack of Type names is stored on its -#: gutter item; :class:`GutterDelegate` reads it to draw the bands. -GUTTER_ROLE = cast( - "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 1 -) - -#: The mock's TINTS, light values only (D21: no dark theme): ``tint`` and -#: ``tintAlt`` are the row background of a claimed row (``tintAlt`` for -#: every other sibling row), ``bar`` the colour of its gutter band. The -#: slot of a Type is its index in this list. -TINT_PALETTE: List[Dict[str, str]] = [ - {"tint": "#e8f1fb", "tintAlt": "#dfe9f6", "bar": "#4a7fc1"}, - {"tint": "#e9f4e9", "tintAlt": "#e0ede0", "bar": "#4f9e57"}, - {"tint": "#f6efe4", "tintAlt": "#efe7db", "bar": "#b98a3e"}, - {"tint": "#f9ecec", "tintAlt": "#f2e3e3", "bar": "#b5605f"}, - {"tint": "#e5f4f2", "tintAlt": "#dcece9", "bar": "#3f9490"}, -] - -#: The palette as QColors, in the same slot order. -TINT_COLOURS: List[Dict[str, QtGui.QColor]] = [ - {name: QtGui.QColor(value) for name, value in entry.items()} - for entry in TINT_PALETTE -] - - -@dataclass -class Claim: - """What the tree shows for one row that Types carry (the mock's - ``claims()``): the Claiming Type whose tint the row shows, the Instance - submodule path that claims it, and every Type carrying the row, - outermost first (the gutter draws one band per Type, up to three).""" - - type: str - instance: str - stack: List[str] - - -def _nested_claim_prefixes( - blueprint: PMTypeBluePrint, - types: Mapping[str, PMTypeBluePrint], -) -> Dict[str, str]: - """Map every effective path of the Type ``blueprint`` that a Nested - Type defines to the dotted submodule chain under which its defining - Type sits (the mock's ``at``): a ``qubit`` nesting a ``readout`` at its - submodule ``readout``, with the ``readout`` nesting a ``pulse_window`` - at ``pw``, maps the effective path ``readout.pw.win`` to - ``readout.pw``. - - Mirrors how ``params.py`` expands the effective set - (``_collect_effective``): the entries a Type defines itself are left - out (they claim at the Instance itself) and each Nested Type's own - entries are recorded under the chain that leads to it. - """ - at_by_path: Dict[str, str] = {} - - def walk(blueprint: PMTypeBluePrint, prefix: str, seen: Tuple[str, ...]) -> None: - for submodule, nested_name in blueprint.nested.items(): - if nested_name in seen: - continue # cycles are refused by the Parameter Manager - nested = types.get(nested_name) - if nested is None: - continue - at = prefix + submodule - for path, spec in nested.effective.items(): - if spec.get("from_type") == nested_name: - at_by_path[f"{at}.{path}"] = at - walk(nested, f"{at}.", seen + (nested_name,)) - - walk(blueprint, "", (blueprint.name,)) - return at_by_path - - -def _carries_effective_set( - instance: str, - effective: Mapping[str, Mapping[str, str]], - parameters: Mapping[str, str], -) -> bool: - """Whether the candidate Instance ``instance`` carries every path of - the effective set ``effective`` with the unit the Type declares (D12): - matching requires existence and unit, compared as strings; values are - irrelevant.""" - prefix = f"{instance}." - for path, spec in effective.items(): - if parameters.get(prefix + path) != spec["unit"]: - return False - return True - - -def _instance_candidates(parameters: Mapping[str, str]) -> List[str]: - """Every submodule path the parameter rows imply, sorted: every proper - dotted prefix of a parameter path, never the root and never anything - under the Globals submodule (D12). This is the candidate set both - :func:`compute_claims` and :func:`instances_of_type` match against.""" - candidates = set() - for path in parameters: - segments = path.split(".") - for depth in range(1, len(segments)): - candidate = ".".join(segments[:depth]) - if "_globals" in candidate.split("."): - continue # Globals is excluded from matching at any depth - candidates.add(candidate) - return sorted(candidates) - - -def compute_claims( - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], -) -> Dict[str, Claim]: - """The mock's ``claims()`` ported to the client-side state (plan task - 5.2): which Type claims each row of the Parameter Manager tree, and - which stack of Types carries it. - - :param types: the Parameter Manager's Types (``PMState.types``), each - as its :class:`PMTypeBluePrint`. - :param parameters: every parameter row of the tree as ``{path relative - to the Parameter Manager: unit}``. - :return: for every claimed parameter path and submodule path, its - :class:`Claim`. - - Matching mirrors ``ParameterManager.instances_of`` (D12) client-side: - a candidate is every submodule path derived from the parameter paths - (every proper dotted prefix; never the root, never anything under - Globals) and it is an Instance when it carries every effective path - with the declared unit. The Claiming Type is the innermost (the - longest Instance path), then the largest effective set, then the Type - name. A Nested Type claims at and below its submodule, so a row it - defines is claimed by it, with the outer Types behind it in the stack. - """ - # candidate Instances: every proper dotted prefix of a parameter path - candidates = _instance_candidates(parameters) - - claims_by_path: Dict[str, List[Tuple[str, str, int]]] = {} - winning: Dict[str, Tuple[str, str, int]] = {} - - def put(path: str, type_name: str, instance: str, size: int) -> None: - # one (Type, Instance, effective set size) claim, as the mock's - # all/map pair; the winner keeps the innermost Instance, then the - # largest effective set, then the Type name - claim = (type_name, instance, size) - claims_by_path.setdefault(path, []).append(claim) - old = winning.get(path) - if old is None or (-len(instance), -size, type_name) < ( - -len(old[1]), - -old[2], - old[0], - ): - winning[path] = claim - - for type_name, blueprint in types.items(): - effective = blueprint.effective - if not effective: - continue # an empty Type has no Instances - size = len(effective) - at_by_path = _nested_claim_prefixes(blueprint, types) - for instance in candidates: - if not _carries_effective_set(instance, effective, parameters): - continue - # the Instance row itself is claimed by its Type, as in the mock - put(instance, type_name, instance, size) - for path in effective: - # every row at and above the parameter, down to the - # parameter itself, is claimed at the Instance - at = at_by_path.get(path, "") - spec = effective[path] - owner_instance = f"{instance}.{at}" if at else None - at_depth = len(at.split(".")) if at else 0 - segments = path.split(".") - for depth in range(1, len(segments) + 1): - row = f"{instance}.{'.'.join(segments[:depth])}" - put(row, type_name, instance, size) - if owner_instance is not None and depth >= at_depth: - # the Nested Type claims at and below its submodule - put(row, spec["from_type"], owner_instance, size) - - claims: Dict[str, Claim] = {} - for path, path_claims in claims_by_path.items(): - # the stack is every Type carrying the row, outermost first - # (shortest Instance path, then the larger effective set), - # de-duplicated by Type - stack: List[str] = [] - for name in [ - entry[0] - for entry in sorted( - path_claims, key=lambda entry: (len(entry[1]), -entry[2], entry[0]) - ) - ]: - if name not in stack: - stack.append(name) - type_name, instance, _ = winning[path] - claims[path] = Claim(type=type_name, instance=instance, stack=stack) - return claims - - -class TypePalette: - """Assigns the fixed tint palette's slots to the Types the GUI knows. - - A Type keeps its slot while it exists: the slot is assigned when the - GUI first sees the Type (in ``PMState.types`` order after a refresh, - then each new Type from a ``pm-type-update`` Broadcast), it never - changes while the Type is in the state, and it is freed when the Type - is removed. A new Type takes the lowest free slot, or slot 0 when all - five are used (the mock's ``freeTint`` recycles when exhausted). - """ - - def __init__(self) -> None: - self.slots: Dict[str, int] = {} - - def sync(self, type_names: Any) -> None: - """Free the slots of Types that are gone and assign slots to new - ones, in the given creation order. - - :param type_names: the names of the Types the GUI knows - (``PMState.types``). - """ - names = list(type_names) - for name in [known for known in self.slots if known not in names]: - del self.slots[name] - used = set(self.slots.values()) - for name in names: - if name in self.slots: - continue - slot = next( - (index for index in range(len(TINT_PALETTE)) if index not in used), - 0, - ) - self.slots[name] = slot - used.add(slot) - - def colours(self, type_name: str) -> Optional[Dict[str, QtGui.QColor]]: - """The palette entry of the Type ``type_name`` (``tint``, - ``tintAlt`` and ``bar``), or ``None`` when it has no slot.""" - slot = self.slots.get(type_name) - return None if slot is None else TINT_COLOURS[slot] - - def bar_colour(self, type_name: str) -> Optional[QtGui.QColor]: - """The gutter band colour of the Type ``type_name``.""" - colours = self.colours(type_name) - return None if colours is None else colours["bar"] - - -class GutterDelegate(QtWidgets.QStyledItemDelegate): - """Draws the gutter bands of a row's stack of Types into the gutter - column: up to three vertical bands of equal width filling the cell, - one per Type of the stack, outermost first, left to right, in the - Types' ``bar`` colours. A row with no stack paints nothing beyond the - background.""" - - def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: - super().__init__(parent) - # Owned by the Parameter Manager GUI and assigned after the view is - # built; the delegate only reads the Types' colours from it. - self.typePalette: Optional[TypePalette] = None - - def paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, - ) -> None: - opt = QtWidgets.QStyleOptionViewItem(option) - self.initStyleOption(opt, index) - opt.text = "" - # the background first (alternating row or Type tint), then the bands - widget = opt.widget - style = ( - widget.style() if widget is not None else QtWidgets.QApplication.style() - ) - style.drawControl( - QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget - ) - if self.typePalette is None: - return - stack = index.data(GUTTER_ROLE) - if not stack: - return - bandWidth = opt.rect.width() / len(stack) - for band, type_name in enumerate(stack): - colour = self.typePalette.bar_colour(type_name) - if colour is None: - continue - painter.fillRect( - QtCore.QRectF( - opt.rect.x() + band * bandWidth, - opt.rect.y(), - bandWidth, - opt.rect.height(), - ), - colour, - ) - - def sizeHint( - self, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, - ) -> QtCore.QSize: - return QtCore.QSize( - GUTTER_WIDTH, super().sizeHint(option, index).height() - ) - - -# ----------------- Parameter Manager tints - Ending ----------------------------------- - - -# ----------------- Parameter Manager Locks - Beginning -------------------------------- - - -#: Logical index of the Lock column of :class:`ModelParameterManager` -#: (plan task 5.3). The existing columns keep their indexes: name (0), -#: unit (1), delegate (2), gutter (3). The view shows the Lock column -#: between the unit and the delegate column. -LOCK_COLUMN = 4 - -#: Fixed default pixel width of the Lock column in the view (the user can -#: resize it: the section is Interactive). -LOCK_COLUMN_WIDTH = 140 - -#: The mock's one purple (its ``--log-value`` token): the fill of a row's -#: lock button while its Lock is locked. -LOCK_COLOUR = "#7e5bef" - - -def lock_button_tooltip(locked: bool, target: str) -> str: - """The lock/relock button's tooltip for one Lock state (the mock's - strings), with ``target`` relative to the Parameter Manager. Shared by - the tree's per-row widget (plan task 5.3) and the Locks panel (plan - task 5.4).""" - if locked: - return f"locked to {target} — unlock and go back to its own value" - return f"unlocked — lock to {target} again" - - -def make_lock_button( - parent: QtWidgets.QWidget, locked: bool, target: Optional[str] = None -) -> QtWidgets.QPushButton: - """The lock/relock toggle button shared by the tree's per-row widget - (plan task 5.3) and the Locks panel (plan task 5.4): the lock icon and - the purple ``locked`` fill. ``target`` is the Target relative to the - Parameter Manager for the state tooltip; the tree's delegate passes - ``None`` and leaves the tooltip to - :meth:`ParameterManagerGui._update_row_lock_widget`.""" - button = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/lock.svg"), "", parent=parent - ) - button.setProperty("locked", locked) - button.setStyleSheet( - f"QPushButton[locked=\"true\"] {{ background-color: {LOCK_COLOUR} }}" - ) - if target is not None: - button.setToolTip(lock_button_tooltip(locked, target)) - keepSmallHorizontally(button) - return button - - -def relative_path(full: str, instrument_name: str) -> str: - """The path relative to the Parameter Manager: ``full`` with the - ``.`` prefix stripped. ``PMLockBluePrint.target`` - stores the full dotted path, while model item names and every string - the GUI shows the user are relative to the Parameter Manager.""" - prefix = f"{instrument_name}." - return full[len(prefix):] if full.startswith(prefix) else full - - -def lock_column_text( - path: str, - locks: Mapping[str, PMLockBluePrint], - instrument_name: str, -) -> str: - """The text the Lock column shows for the parameter row ``path`` (a - path relative to the Parameter Manager), computed client-side over the - state's Locks (plan task 5.3; the mock's lock cell). - - A Follower shows its own Lock state: ``locked to `` while - locked, ``unlocked · `` (middle dot) while unlocked, with the - Target relative to the Parameter Manager. A parameter that is no - Follower but the Target of ``N`` Locks — locked and unlocked alike, - the way :meth:`ParameterManager.followers_of` counts — shows - ``target ×N`` (multiplication sign). Every other row shows nothing. - - A row that is both Follower and Target shows its Follower text, which - wins over the Target note (the mock's ``rec.lockedTo || srcNote(p)``). - """ - lock = locks.get(path) - if lock is not None: - # the Follower's own Lock state wins over the Target note - target = relative_path(lock.target, instrument_name) - if lock.locked: - return f"locked to {target}" - return f"unlocked · {target}" - full_path = f"{instrument_name}.{path}" - count = sum(1 for other in locks.values() if other.target == full_path) - if count: - return f"target ×{count}" - return "" - - -def followers_reaching( - path: str, - locks: Mapping[str, PMLockBluePrint], - instrument_name: str, -) -> List[str]: - """Paths (relative to the Parameter Manager) of every Follower whose - locked Lock targets the parameter at ``path``, directly or over a - chain of locked Locks. - - Only locked hops count (D7): an unlocked Lock answers ``get`` with its - own value, so the Followers behind it do not see an update made past - it. The walk follows each hop's Target and stops there — no infinite - loop on a cycle, and every Follower appears once. - """ - prefix = f"{instrument_name}." - found: List[str] = [] - seen: set = set() - targets = [prefix + path] - index = 0 - while index < len(targets): - current = targets[index] - index += 1 - for follower, lock in locks.items(): - if not lock.locked or lock.target != current or follower in seen: - continue - seen.add(follower) - found.append(follower) - targets.append(prefix + follower) - return found - - -def rank_lock_targets( - follower: str, - candidates: Iterable[str], - claims: Mapping[str, Claim], - arm_rel: Optional[str] = None, -) -> List[str]: - """The arm strip's Target candidates in the mock's completer order. - - ``arm_rel`` is the Follower's path relative to its Instance (the part - behind the Claiming Type's Instance path), or ``None`` when the - Follower is claimed by no Type; an explicit ``arm_rel`` argument - overrides it, which the Types tab's Type Lock re-target (plan task - 5.5) uses to rank for a Type's entry path — there is no claimed - Follower and so nothing to exclude. Rank 0: the candidate's own - relative path equals ``arm_rel`` (the same leaf on a sibling Instance, - the mock's first pick). Rank 1: ``.`` occurs in the candidate - (a submodule on the way). Rank 2: everything else. Equal ranks order - alphabetically; the Follower itself is never a candidate. Cycles are - not filtered here: the Server refuses them and the arm strip shows its - error text. - """ - if arm_rel is None: - follower_claim = claims.get(follower) - arm_rel = ( - follower[len(follower_claim.instance) + 1:] - if follower_claim is not None - else None - ) - - def own_rel(candidate: str) -> Optional[str]: - claim = claims.get(candidate) - if claim is None: - return None - return candidate[len(claim.instance) + 1:] - - ranked: List[Tuple[int, str]] = [] - for candidate in candidates: - if candidate == follower: - continue # the Follower itself is never a candidate - rel = own_rel(candidate) - if arm_rel is not None and rel == arm_rel: - rank = 0 - elif arm_rel is not None and f".{arm_rel}" in candidate: - rank = 1 - else: - rank = 2 - ranked.append((rank, candidate)) - ranked.sort(key=lambda entry: (entry[0], entry[1])) - return [path for _, path in ranked] - - -@dataclass -class LockRow: - """One row of the Locks panel (plan task 5.4): a Target of one or more - Locks — plain, or the Target of a Type Lock — and the Followers beneath - it, recursively for chains. ``type_locks`` holds every ``(Type name, - entry path)`` whose Type Lock Target the row is; ``lock`` is the row's - own Lock (``None`` for a plain Target).""" - - path: str - type_locks: List[Tuple[str, str]] - lock: Optional[PMLockBluePrint] - children: List["LockRow"] - - -def lock_root( - path: str, - locks: Mapping[str, PMLockBluePrint], - instrument_name: str, -) -> str: - """The end of the chain of locked Locks that starts at ``path`` (the - mock's ``root``): the parameter a locked read at ``path`` finally asks. - Only locked hops count (D7): an unlocked Lock answers ``get`` with its - own value, so the walk stops there. A ``seen`` set guards against a - cycle. Paths are relative to the Parameter Manager, except the stored - ``PMLockBluePrint.target``, which is relativized on the way.""" - current = path - seen: set = set() - while current not in seen: - seen.add(current) - lock = locks.get(current) - if lock is None or not lock.locked: - return current - current = relative_path(lock.target, instrument_name) - return current - - -def build_lock_rows( - locks: Mapping[str, PMLockBluePrint], - types: Mapping[str, PMTypeBluePrint], - instrument_name: str, -) -> List[LockRow]: - """The Locks panel's rows from the client-side state (plan task 5.4; - the mock's locks-panel walk). - - ``locks`` maps each Follower's path (relative to the Parameter - Manager) to its :class:`PMLockBluePrint`; ``types`` maps each Type's - name to its :class:`PMTypeBluePrint`. An unlocked Lock still - remembers its Target (D5), so a Follower's Lock names its Target - whether the Lock is locked or not. - - The Targets are the unique Targets of the Locks, in ``locks`` order. - The roots are the Targets that carry no Lock of their own, the Type - Lock Targets first (a stable sort, like the mock's), each walked - recursively into its Followers — a ``seen`` set guards against loops — - and then any Target the first walk did not reach (the mock's second - pass, e.g. a cycle among Followers). Every row carries its own Lock - (``None`` for a plain Target) and its ``(Type, entry)`` pairs. - """ - - def target_of(follower: str) -> Optional[str]: - lock = locks.get(follower) - return ( - None if lock is None else relative_path(lock.target, instrument_name) - ) - - targets: List[str] = [] - for follower in locks: - target = target_of(follower) - if target is not None and target not in targets: - targets.append(target) - - def type_locks_at(path: str) -> List[Tuple[str, str]]: - found: List[Tuple[str, str]] = [] - for type_name, blueprint in types.items(): - for entry_path, spec in blueprint.parameters.items(): - entry_target = spec.get("target") - if ( - entry_target is not None - and relative_path(entry_target, instrument_name) == path - ): - found.append((type_name, entry_path)) - return found - - def followers(path: str) -> List[str]: - return [ - follower for follower in locks if target_of(follower) == path - ] - - rows: List[LockRow] = [] - seen: set = set() - - def walk(path: str) -> Optional[LockRow]: - if path in seen: - return None - seen.add(path) - row = LockRow( - path=path, - type_locks=type_locks_at(path), - lock=locks.get(path), - children=[], - ) - for child_path in followers(path): - child = walk(child_path) - if child is not None: - row.children.append(child) - return row - - # Type Lock Targets first, plain Targets follow — a stable sort, - # like the mock's - roots = [target for target in targets if target not in locks] - roots.sort(key=lambda target: 0 if type_locks_at(target) else 1) - for target in roots: - row = walk(target) - if row is not None: - rows.append(row) - # the mock's second pass: any Target the first walk did not reach - for target in targets: - row = walk(target) - if row is not None: - rows.append(row) - return rows - - -def _lock_row_paths(rows: List[LockRow]) -> List[str]: - """Every row path of the built rows, depth first.""" - paths: List[str] = [] - for row in rows: - paths.append(row.path) - paths.extend(_lock_row_paths(row.children)) - return paths - - -class LockArmStrip(QtWidgets.QWidget): - """The arm strip under the toolbar while a Lock's Target is being - picked (plan task 5.3): a label naming the Follower, a line edit with - a completer over the ranked candidate paths, a Cancel button and an - error label for the Server's refusal text. - - Picking works three ways: a completion from the popup, Return with the - exact typed path (or the first completion the completer filters for - the typed text; a text that matches no candidate picks nothing), and - clicking a tree row — the last one is wired by the Parameter Manager - GUI, which owns the strip. Cancel is the button or Escape while the - strip or one of its children has focus.""" - - #: Signal(str) - #: Emitted when a Target was picked. The path is relative to the - #: Parameter Manager. - targetPicked = QtCore.Signal(str) - - #: Signal() - #: Emitted when the user cancels the pick (Cancel button or Escape). - cancelled = QtCore.Signal() - - def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: - super().__init__(parent) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self.label = QtWidgets.QLabel(self) - - self.lineEdit = QtWidgets.QLineEdit(self) - self.lineEdit.setPlaceholderText("type part of the target path, or click a row") - - # the completer keeps the ranked candidate order (UnsortedModel) - # and filters it by what the user typed - self.completerModel = QtCore.QStringListModel(self) - self.completer = QtWidgets.QCompleter(self) - self.completer.setModel(self.completerModel) - self.completer.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) - self.completer.setCaseSensitivity( - QtCore.Qt.CaseSensitivity.CaseInsensitive - ) - self.completer.setModelSorting( - QtWidgets.QCompleter.ModelSorting.UnsortedModel - ) - self.lineEdit.setCompleter(self.completer) - - self.cancelButton = QtWidgets.QPushButton("Cancel", self) - - self.errorLabel = QtWidgets.QLabel(self) - self.errorLabel.setStyleSheet( - "QLabel { background-color: red; color: white; font-weight: bold }" - ) - self.errorLabel.setVisible(False) - - layout.addWidget(self.label) - layout.addWidget(self.lineEdit, 1) - layout.addWidget(self.cancelButton) - layout.addWidget(self.errorLabel) - self.setLayout(layout) - - self.completer.activated[str].connect(self.targetPicked) # type: ignore[index] - self.lineEdit.returnPressed.connect(self._on_return_pressed) - self.cancelButton.clicked.connect(self.cancelled) - - self.escShortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Escape"), self) - self.escShortcut.setContext( - QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut - ) - self.escShortcut.activated.connect(self.cancelled) - - @QtCore.Slot() - def _on_return_pressed(self) -> None: - """Pick the exact typed path, or the first completion the - completer filters for the typed text (the mock's Enter picks the - first match); a text that matches no candidate picks nothing.""" - text = self.lineEdit.text().strip() - if not text: - return - if text in self.completerModel.stringList(): - self.targetPicked.emit(text) - return - # the completer's filtered matches for what was typed, in ranked - # order; its filter mode (MatchContains) and case sensitivity apply - self.completer.setCompletionPrefix(text) - if self.completer.completionCount() > 0: - first = self.completer.completionModel().index(0, 0) - self.targetPicked.emit( - self.completer.completionModel().data( - first, QtCore.Qt.ItemDataRole.DisplayRole - ) - ) - - def arm(self, follower: str, candidates: List[str]) -> None: - """Arm the strip for the Follower at ``follower``: name it in the - label, load the ranked candidates into the completer, clear the - line edit and any error, show the strip and focus the line edit.""" - self.label.setText(f"Target for {follower}") - self.completerModel.setStringList(candidates) - self.lineEdit.clear() - self.clear_error() - self.setVisible(True) - self.lineEdit.setFocus() - - def show_error(self, text: str) -> None: - """Show the Server's error text on the error label.""" - self.errorLabel.setText(text) - self.errorLabel.setVisible(True) - - def clear_error(self) -> None: - """Hide and clear the error label (the next pick or cancel does - this).""" - self.errorLabel.setText("") - self.errorLabel.setVisible(False) - - def disarm(self) -> None: - """Hide the strip and clear it.""" - self.setVisible(False) - self.lineEdit.clear() - self.clear_error() - - -#: The Locks panel's default note (the mock's ``lockNote``, in glossary -#: words): shown until an action error or a skipped-Lock warning replaces -#: it. -LOCK_PANEL_NOTE = ( - "A Type Lock row — marked with its Type — holds one value for every " - "Instance of that Type. Unlock a Follower to let it keep its own " - "value, remove its Lock to take it out; the lock button on the Type " - "Lock row locks them all again." -) - -#: Fixed pixel width of the Locks panel's value column (the mock's value -#: column) and of its buttons column. -LOCK_PANEL_VALUE_WIDTH = 200 -LOCK_PANEL_BUTTONS_WIDTH = 84 - -#: Data role under which a Locks panel row's path (relative to the -#: Parameter Manager) is stored on its first item, so the rows can be -#: found again after a rebuild. -LOCK_ROW_ROLE = cast( - "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 2 -) - - -class LocksPanel(QtWidgets.QWidget): - """The Locks panel right of the Parameter Manager tree (plan task 5.4; - the mock's locks panel): one root row per Target — the Type Lock - Targets first, labelled with their Type — with each Target's Followers - beneath it, recursively for chains. - - A Target row holds a value editor (a plain ``set`` on the Target); a - locked Follower row shows its value read-only, and every Follower row - that is not a Type Lock row carries the lock/relock toggle and the - remove button. A Type Lock row carries "lock all" and "remove rule". - The panel never talks to the Server itself: every action is emitted as - a signal — - ``toggleLockRequested``, ``removeLockRequested``, ``lockAllRequested``, - ``removeRuleRequested`` and ``lockSelectionRequested`` — and the - Parameter Manager GUI, which owns the panel, performs it and reports - errors and skipped Locks on the note label.""" - - #: Signal(str) - #: Emitted when the user presses a Follower row's lock/relock button; - #: the path is relative to the Parameter Manager. - toggleLockRequested = QtCore.Signal(str) - - #: Signal(str) - #: Emitted when the user presses a Follower row's remove button; the - #: path is relative to the Parameter Manager. - removeLockRequested = QtCore.Signal(str) - - #: Signal(str, str, str) - #: Emitted when the user presses a Type Lock row's "lock all" button: - #: the Type's name, the entry path, and the entry's stored Target - #: relative to the Parameter Manager. - lockAllRequested = QtCore.Signal(str, str, str) - - #: Signal(str, str) - #: Emitted when the user presses a Type Lock row's "remove rule" - #: button: the Type's name and the entry path. - removeRuleRequested = QtCore.Signal(str, str) - - #: Signal() - #: Emitted when the user presses "Lock selection to…". - lockSelectionRequested = QtCore.Signal() - - def __init__( - self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None - ) -> None: - super().__init__(parent) - self.instrument_name = instrument_name - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self.model = QtGui.QStandardItemModel(0, 3, self) - self.model.setHorizontalHeaderLabels(["locks", "value", ""]) - - self.view = QtWidgets.QTreeView(self) - self.view.setModel(self.model) - self.view.setHeaderHidden(False) - self.view.setAlternatingRowColors(True) - self.view.setEditTriggers( - QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers - ) - header = self.view.header() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) - header.resizeSection(1, LOCK_PANEL_VALUE_WIDTH) - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Fixed) - header.resizeSection(2, LOCK_PANEL_BUTTONS_WIDTH) - - self.lockSelectionButton = QtWidgets.QPushButton( - "Lock selection to…", self - ) - self.selectedLabel = QtWidgets.QLabel(self) - self.selectedLabel.setText("no parameter selected") - - self.noteLabel = QtWidgets.QLabel(self) - self.noteLabel.setWordWrap(True) - self.noteLabel.setText(LOCK_PANEL_NOTE) - - layout.addWidget(self.view, 1) - selectionRow = QtWidgets.QHBoxLayout() - selectionRow.setContentsMargins(0, 0, 0, 0) - selectionRow.addWidget(self.lockSelectionButton) - selectionRow.addWidget(self.selectedLabel, 1) - layout.addLayout(selectionRow) - layout.addWidget(self.noteLabel) - self.setLayout(layout) - - # The widgets of every panel row, keyed by the row path (relative - # to the Parameter Manager); :meth:`refresh_values` re-reads the - # values without a rebuild. - self.rowWidgets: Dict[str, Dict[str, Any]] = {} - - self.lockSelectionButton.clicked.connect(self.lockSelectionRequested) - - def rebuild( - self, - rows: List[LockRow], - elements: Mapping[str, Any], - types: Mapping[str, PMTypeBluePrint], - locks: Mapping[str, PMLockBluePrint], - ) -> None: - """Rebuild every row from ``rows`` (see :func:`build_lock_rows`). - - ``elements`` maps each row path to the row's parameter object (the - GUI resolves it through the Proxy or the local instrument); - ``types`` and ``locks`` are the client-side state the "lock all" - Target and the tooltips come from. Every row is expanded after the - rebuild; no collapsed state is kept.""" - self.model.removeRows(0, self.model.rowCount()) - self.rowWidgets = {} - self._build_rows(rows, elements, types, locks, self.model.invisibleRootItem()) - self.view.expandAll() - - def refresh_values(self, paths: Iterable[str]) -> None: - """Re-read the value of every named row the panel holds: - editors through :meth:`ParameterWidget.setWidgetFromParameter`, - the read-only labels of locked rows through a fresh ``get``. A row - whose widget is gone — a rebuild replaced it — is skipped.""" - for path in paths: - entry = self.rowWidgets.get(path) - if entry is None: - continue - try: - if entry.get("editor") is not None: - entry["editor"].setWidgetFromParameter() - elif ( - entry.get("label") is not None - and entry.get("element") is not None - ): - entry["label"].setText(str(entry["element"].get())) - except RuntimeError: - logger.debug( - f"Could not refresh the value of {path}. " - "Object is not being shown right now." - ) - - def show_error(self, text: str) -> None: - """Show an action error (the mock's ``lockError``) in red on the - note label.""" - self.noteLabel.setStyleSheet("QLabel { color: red }") - self.noteLabel.setText(text) - - def show_note(self, text: str) -> None: - """Show ``text`` on the note label in the normal colour (a - skipped-Lock warning, for example).""" - self.noteLabel.setStyleSheet("") - self.noteLabel.setText(text) - - def reset_note(self) -> None: - """Restore the default explanatory note.""" - self.show_note(LOCK_PANEL_NOTE) - - def _build_rows( - self, - rows: List[LockRow], - elements: Mapping[str, Any], - types: Mapping[str, PMTypeBluePrint], - locks: Mapping[str, PMLockBluePrint], - parent_item: QtGui.QStandardItem, - ) -> None: - for row in rows: - if row.type_locks: - label = ( - f"[type: {', '.join(t for t, _ in row.type_locks)}] {row.path}" - ) - else: - label = row.path - name_item = QtGui.QStandardItem(label) - name_item.setData(row.path, LOCK_ROW_ROLE) - value_item = QtGui.QStandardItem() - buttons_item = QtGui.QStandardItem() - parent_item.appendRow([name_item, value_item, buttons_item]) - self._build_row_widgets( - row, - elements.get(row.path), - types, - locks, - value_item, - buttons_item, - ) - self._build_rows( - row.children, elements, types, locks, name_item - ) - - def _build_row_widgets( - self, - row: LockRow, - element: Any, - types: Mapping[str, PMTypeBluePrint], - locks: Mapping[str, PMLockBluePrint], - value_item: QtGui.QStandardItem, - buttons_item: QtGui.QStandardItem, - ) -> None: - """Build one row's value cell (a read-only label for a locked - Follower, a value editor for every other row with a parameter) and - its buttons cell (the Type Lock controls, or the Follower's toggle - and remove), and record the widgets in ``rowWidgets``.""" - path = row.path - entry: Dict[str, Any] = { - "element": element, - "editor": None, - "label": None, - "toggle": None, - "remove": None, - "lockAll": None, - "removeRule": None, - } - self.rowWidgets[path] = entry - - if row.lock is not None and row.lock.locked: - # a locked Follower reads its Target's value (D3) and refuses - # writes: a read-only label, like the mock's - target = relative_path(row.lock.target, self.instrument_name) - root = lock_root(path, locks, self.instrument_name) - label = QtWidgets.QLabel(self.view.viewport()) - value = "" - if element is not None: - try: - value = element.get() - except Exception as exc: - logger.debug(f"could not read the value of {path}: {exc}") - label.setText(str(value)) - label.setToolTip(f"locked to {target} — set the value on {root}") - self.view.setIndexWidget(self.model.indexFromItem(value_item), label) - entry["label"] = label - elif element is not None: - editor = ParameterWidget(element, self.view.viewport()) - if row.type_locks: - tooltip = ( - f"set the value — every Instance of " - f"{row.type_locks[0][0]} follows it" - ) - else: - tooltip = "set the Target value — every locked Follower follows it" - editor.setButton.setToolTip(tooltip) - self.view.setIndexWidget(self.model.indexFromItem(value_item), editor) - entry["editor"] = editor - - container: Optional[QtWidgets.QWidget] = None - if row.type_locks: - # the Type Lock controls; like the mock, the first (Type, - # entry) pair acts when several share the Target - type_name, entry_path = row.type_locks[0] - blueprint = types.get(type_name) - spec = ( - blueprint.parameters.get(entry_path, {}) - if blueprint is not None - else {} - ) - stored = spec.get("target") - stored_relative = ( - relative_path(stored, self.instrument_name) - if stored is not None - else path - ) - container = QtWidgets.QWidget(self.view.viewport()) - buttons_layout = QtWidgets.QHBoxLayout(container) - buttons_layout.setContentsMargins(0, 0, 0, 0) - lock_all = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/lock.svg"), "", parent=container - ) - lock_all.setToolTip(f"lock every Instance of {type_name} to this again") - keepSmallHorizontally(lock_all) - lock_all.pressed.connect( - lambda: self.lockAllRequested.emit( - type_name, entry_path, stored_relative - ) - ) - remove_rule = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/delete.svg"), "", parent=container - ) - remove_rule.setStyleSheet("QPushButton { background-color: salmon }") - remove_rule.setToolTip( - "remove the Type Lock — the Instances' Locks stay until " - "removed one by one" - ) - keepSmallHorizontally(remove_rule) - remove_rule.pressed.connect( - lambda: self.removeRuleRequested.emit(type_name, entry_path) - ) - buttons_layout.addWidget(lock_all) - buttons_layout.addWidget(remove_rule) - entry["lockAll"] = lock_all - entry["removeRule"] = remove_rule - elif row.lock is not None: - # a Follower's lock/relock toggle and remove button - container = QtWidgets.QWidget(self.view.viewport()) - buttons_layout = QtWidgets.QHBoxLayout(container) - buttons_layout.setContentsMargins(0, 0, 0, 0) - target = relative_path(row.lock.target, self.instrument_name) - toggle = make_lock_button(container, row.lock.locked, target) - toggle.pressed.connect( - lambda follower=path: self.toggleLockRequested.emit(follower) - ) - remove = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/delete.svg"), "", parent=container - ) - remove.setStyleSheet("QPushButton { background-color: salmon }") - remove.setToolTip(f"remove the Lock — {path} keeps its own value") - keepSmallHorizontally(remove) - remove.pressed.connect( - lambda follower=path: self.removeLockRequested.emit(follower) - ) - buttons_layout.addWidget(toggle) - buttons_layout.addWidget(remove) - entry["toggle"] = toggle - entry["remove"] = remove - if container is not None: - self.view.setIndexWidget( - self.model.indexFromItem(buttons_item), container - ) - - -# ----------------- Parameter Manager Locks - Ending ----------------------------------- - - -# ----------------- Parameter Manager Types tab - Beginning ---------------------------- - - -@dataclass -class EntryRow: - """One row of the Types tab's entries pane (plan task 5.5; the mock's - ``tParamRows``): a submodule row of the selected Type's tree, or one - entry of it. - - ``kind`` is ``"submodule"`` or ``"entry"``. A submodule row carries - ``nested_type`` — the Type required at that submodule, or ``None`` for - a structural row that only carries the rows below it. An entry row - carries - the effective entry's ``unit`` and defining Type (``from_type``), - whether the selected Type defines the entry itself (``own``), its - ``default`` — an own entry's stored default, a Nested Type entry's - default as stored on the defining Type — and, own entries only, the - ``target`` of the entry's Type Lock relative to the Parameter Manager - (``None`` while it has none). - """ - - path: str - kind: str - unit: str = "" - nested_type: Optional[str] = None - own: bool = False - from_type: Optional[str] = None - default: Any = None - target: Optional[str] = None - - -def _nested_type_at( - blueprint: PMTypeBluePrint, - types: Mapping[str, PMTypeBluePrint], - submodule: str, -) -> Optional[str]: - """The Type required at the submodule ``submodule`` (a dotted path - relative to the Type ``blueprint``): the walk follows the ``nested`` - maps down the segments, the way :func:`_nested_claim_prefixes` walks. - ``None`` when no Nested Type is required there — a structural row — - or when a nested Type of the chain is missing from ``types``.""" - current = blueprint - for segment in submodule.split("."): - if current is None: - return None - nested_name = current.nested.get(segment) - if nested_name is None: - return None - current = types.get(nested_name) - return current.name if current is not None else None - - -def type_entry_rows( - type_name: str, - types: Mapping[str, PMTypeBluePrint], - instrument_name: str = "", -) -> List[EntryRow]: - """The entries-pane rows of the Type ``type_name`` (plan task 5.5): - its effective parameter set as a segment-sorted tree of submodule and - entry rows. - - The sort is segment-wise like the mock's (paths order by their dotted - segments), so a submodule row sorts directly before the rows below it - and the list reads as a tree in order. - - An entry the Type defines itself (``from_type == type_name``) is - ``own``: its ``default`` and ``target`` come from the Type's own - entry, with the stored Type Lock Target relativized with - ``instrument_name``. An entry a Nested Type defines shows that Type - as ``from_type`` and the defining Type's own default for the path - relative to it (the mock's ``ownerRel``). - - :param type_name: the selected Type's name. - :param types: the Parameter Manager's Types (``PMState.types``). - :param instrument_name: the Parameter Manager's name, for - relativizing the stored Type Lock Targets; without it the stored - full-form Targets are returned unchanged. - :return: the rows, parents before children. - """ - blueprint = types.get(type_name) - if blueprint is None: - return [] - at_by_path = _nested_claim_prefixes(blueprint, types) - rows: List[EntryRow] = [] - submodule_paths: set = set() - for path in sorted(blueprint.effective, key=lambda entry: entry.split(".")): - segments = path.split(".") - for depth in range(1, len(segments)): - submodule = ".".join(segments[:depth]) - if submodule in submodule_paths: - continue - submodule_paths.add(submodule) - rows.append( - EntryRow( - path=submodule, - kind="submodule", - nested_type=_nested_type_at(blueprint, types, submodule), - ) - ) - spec = blueprint.effective[path] - from_type = spec["from_type"] - own = from_type == type_name - if own: - entry = blueprint.parameters.get(path, {}) - default = entry.get("default") - target = entry.get("target") - if target is not None and instrument_name: - target = relative_path(target, instrument_name) - else: - at = at_by_path.get(path, "") - relative = path[len(at) + 1:] if at else path - defining = types.get(from_type) - default = ( - defining.parameters.get(relative, {}).get("default") - if defining is not None - else None - ) - target = None - rows.append( - EntryRow( - path=path, - kind="entry", - unit=spec["unit"], - own=own, - from_type=from_type, - default=default, - target=target, - ) - ) - return rows - - -def instances_of_type( - type_name: str, - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], -) -> List[str]: - """Paths (relative to the Parameter Manager) of every Instance of the - Type ``type_name``, computed client-side over the model's parameter - rows (plan task 5.5; the mock's ``instancesOf``): the same candidate - rules :func:`compute_claims` matches by — every proper dotted prefix, - never the root, never anything under Globals — carrying every - effective path with the declared unit (D12). An empty Type has no - Instances. The Instances are sorted, for a stable pane order.""" - blueprint = types.get(type_name) - if blueprint is None: - return [] - effective = blueprint.effective - if not effective: - return [] - return [ - candidate - for candidate in _instance_candidates(parameters) - if _carries_effective_set(candidate, effective, parameters) - ] - - -def also_types( - instance: str, - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], -) -> List[str]: - """Every Type the submodule ``instance`` is an Instance of (plan task - 5.5; the mock's ``also`` cell), in ``types`` order. The Types pane - shows the ones besides the selected Type as ``also , ``.""" - return [ - type_name - for type_name in types - if instance in instances_of_type(type_name, types, parameters) - ] - - -def parse_default_text(text: str) -> Any: - """The value a default line edit's text stands for: ``None`` when the - text is empty, otherwise the text parsed with ``ast.literal_eval``, - falling back to the raw string when it does not parse.""" - if text.strip() == "": - return None - try: - return ast.literal_eval(text) - except (ValueError, SyntaxError): - return text - - -#: Fixed pixel widths of the entries pane's unit, "locked to" and default -#: columns (the mock's 60/200/252 trio). -ENTRIES_UNIT_WIDTH = 60 -ENTRIES_LOCK_WIDTH = 210 -ENTRIES_DEFAULT_WIDTH = 250 - -#: Fixed pixel widths of the instances pane's parameter-count, "also" and -#: button columns (the mock's 150/160/110 trio). -INSTANCES_COUNT_WIDTH = 110 -INSTANCES_ALSO_WIDTH = 160 -INSTANCES_BUTTON_WIDTH = 80 - - -class TypesPane(QtWidgets.QWidget): - """The Types tab (plan task 5.5; the mock's Types view): three panes - around the selected Type. - - Left: the list of Types — name, number of Instances and number of - effective parameters, each row tinted with the Type's colour — and the - New type strip. Right, above: the entries of the selected Type as a - tree. Own entries carry an editable default (Return or the set button - commits), a Remove button and the Type Lock toggle in the "locked to" - column, with a re-target button and the Target's path while locked. - Entries from Nested Types render read-only with "defined by ". - Submodule rows show ``type: `` in the "locked to" column and, for - the selected Type's own Nested Types, a Remove button. Beneath the - tree run the "Add to type" and "Nested type" strips and a note line. - Right, below: the Instances of the selected Type — name, parameter - count, the other Types the Instance also carries and a Show button — - with the New instance strip and a note line. - - The pane never talks to the Server: every action is emitted as a - signal — ``addTypeRequested``, ``addEntryRequested``, - ``removeEntryRequested``, ``setDefaultRequested``, - ``toggleTypeLockRequested``, ``retargetTypeLockRequested``, - ``addNestedRequested``, ``removeNestedRequested``, - ``addInstanceRequested`` and ``showInstanceRequested`` — and - :class:`ParameterManagerGui`, which owns the pane, performs it and - reports errors and skipped Locks on the pane's note labels. - """ - - #: Signal(str) - #: Emitted when the user presses the New type strip's Add button; - #: the name is trimmed and not empty. - addTypeRequested = QtCore.Signal(str) - - #: Signal(str) - #: Emitted when the selected Type changes (a row click, or a rebuild - #: that had to pick one). - typeSelected = QtCore.Signal(str) - - #: Signal(str, str, str, str) - #: Emitted when the user presses "Add to type": the Type's name, the - #: entry path, the default text and the unit. - addEntryRequested = QtCore.Signal(str, str, str, str) - - #: Signal(str, str) - #: Emitted when the user presses an own entry's Remove button: the - #: Type's name and the entry path. - removeEntryRequested = QtCore.Signal(str, str) - - #: Signal(str, str, str) - #: Emitted when the user commits an own entry's default editor - #: (Return or the set button): the Type's name, the entry path and - #: the editor's text. - setDefaultRequested = QtCore.Signal(str, str, str) - - #: Signal(str, str) - #: Emitted when the user presses a Type Lock toggle: the Type's name - #: and the entry path. The GUI locks or unlocks from the entry's - #: stored Target. - toggleTypeLockRequested = QtCore.Signal(str, str) - - #: Signal(str, str) - #: Emitted when the user presses a locked entry's re-target button: - #: the Type's name and the entry path. - retargetTypeLockRequested = QtCore.Signal(str, str) - - #: Signal(str, str, str) - #: Emitted when the user presses "Add nested type": the Type's name, - #: the submodule and the Nested Type's name. - addNestedRequested = QtCore.Signal(str, str, str) - - #: Signal(str, str) - #: Emitted when the user presses an own Nested Type's Remove button: - #: the Type's name and the submodule. - removeNestedRequested = QtCore.Signal(str, str) - - #: Signal(str, str) - #: Emitted when the user presses the New instance strip's button: the - #: Type's name and the Instance's name. - addInstanceRequested = QtCore.Signal(str, str) - - #: Signal(str, str) - #: Emitted when the user presses an instance row's Show button: the - #: Type's name and the Instance's name. - showInstanceRequested = QtCore.Signal(str, str) - - def __init__( - self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None - ) -> None: - super().__init__(parent) - self.instrument_name = instrument_name - - # the selected Type, and one requested while the Server call that - # creates it is still in flight (honoured on the next rebuild) - self.selectedType: Optional[str] = None - self.requestedType: Optional[str] = None - # guards the selection slot against the rebuild's own index changes - self._building = False - - # the widgets of the entries rows, keyed by row path; and the Show - # buttons of the instance rows, keyed by instance path - self.entryWidgets: Dict[str, Dict[str, Any]] = {} - self.showButtons: Dict[str, QtWidgets.QPushButton] = {} - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal, self) - - # -- left: the list of Types and the New type strip - typeListPane = QtWidgets.QWidget(self.splitter) - typeListLayout = QtWidgets.QVBoxLayout(typeListPane) - typeListLayout.setContentsMargins(0, 0, 0, 0) - - self.typeModel = QtGui.QStandardItemModel(0, 3, self) - self.typeModel.setHorizontalHeaderLabels(["type", "instances", "parameters"]) - self.typeList = QtWidgets.QTreeView(typeListPane) - self.typeList.setModel(self.typeModel) - self.typeList.setRootIsDecorated(False) - self.typeList.setEditTriggers( - QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers - ) - self.typeList.setAlternatingRowColors(True) - typeHeader = self.typeList.header() - typeHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) - for column, width in ((1, 70), (2, 90)): - typeHeader.setSectionResizeMode( - column, QtWidgets.QHeaderView.ResizeMode.Fixed - ) - typeHeader.resizeSection(column, width) - - typeStrip = QtWidgets.QHBoxLayout() - typeStrip.setContentsMargins(0, 0, 0, 0) - typeStrip.addWidget(QtWidgets.QLabel("New type:")) - self.newTypeEdit = QtWidgets.QLineEdit(typeListPane) - self.newTypeEdit.setPlaceholderText("cavity") - self.addTypeButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/plus-square.svg"), " Add" - ) - keepSmallHorizontally(self.addTypeButton) - typeStrip.addWidget(self.newTypeEdit, 1) - typeStrip.addWidget(self.addTypeButton) - self.typeNote = QtWidgets.QLabel(typeListPane) - - typeListLayout.addWidget(self.typeList, 1) - typeListLayout.addLayout(typeStrip) - typeListLayout.addWidget(self.typeNote) - - # -- right: the entries pane above the instances pane - rightPane = QtWidgets.QSplitter( - QtCore.Qt.Orientation.Vertical, self.splitter - ) - - entriesPane = QtWidgets.QWidget(rightPane) - entriesLayout = QtWidgets.QVBoxLayout(entriesPane) - entriesLayout.setContentsMargins(0, 0, 0, 0) - - self.entriesLabel = QtWidgets.QLabel(entriesPane) - self.entriesModel = QtGui.QStandardItemModel(0, 4, self) - self.entriesModel.setHorizontalHeaderLabels( - ["parameter", "unit", "locked to", "default"] - ) - self.entriesView = QtWidgets.QTreeView(entriesPane) - self.entriesView.setModel(self.entriesModel) - self.entriesView.setEditTriggers( - QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers - ) - self.entriesView.setAlternatingRowColors(True) - entriesHeader = self.entriesView.header() - entriesHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) - for column, width in ( - (1, ENTRIES_UNIT_WIDTH), - (2, ENTRIES_LOCK_WIDTH), - (3, ENTRIES_DEFAULT_WIDTH), - ): - entriesHeader.setSectionResizeMode( - column, QtWidgets.QHeaderView.ResizeMode.Interactive - ) - entriesHeader.resizeSection(column, width) - - entryStrip = QtWidgets.QHBoxLayout() - entryStrip.setContentsMargins(0, 0, 0, 0) - entryStrip.addWidget(QtWidgets.QLabel("Name:")) - self.entryNameEdit = QtWidgets.QLineEdit(entriesPane) - self.entryNameEdit.setPlaceholderText("pulses.pi.drag_multiplier") - entryStrip.addWidget(self.entryNameEdit, 2) - entryStrip.addWidget(QtWidgets.QLabel("Default:")) - self.entryDefaultEdit = QtWidgets.QLineEdit(entriesPane) - entryStrip.addWidget(self.entryDefaultEdit, 1) - entryStrip.addWidget(QtWidgets.QLabel("Unit:")) - self.entryUnitEdit = QtWidgets.QLineEdit(entriesPane) - entryStrip.addWidget(self.entryUnitEdit, 1) - self.addEntryButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/plus-square.svg"), "Add to type" - ) - keepSmallHorizontally(self.addEntryButton) - entryStrip.addWidget(self.addEntryButton) - - nestedStrip = QtWidgets.QHBoxLayout() - nestedStrip.setContentsMargins(0, 0, 0, 0) - nestedStrip.addWidget(QtWidgets.QLabel("Nested type:")) - self.nestedTypeCombo = QtWidgets.QComboBox(entriesPane) - nestedStrip.addWidget(self.nestedTypeCombo, 2) - nestedStrip.addWidget(QtWidgets.QLabel("at:")) - self.nestedAtEdit = QtWidgets.QLineEdit(entriesPane) - self.nestedAtEdit.setPlaceholderText("readout") - nestedStrip.addWidget(self.nestedAtEdit, 1) - self.addNestedButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/plus-square.svg"), "Add nested type" - ) - self.addNestedButton.setToolTip("require another Type at that submodule") - keepSmallHorizontally(self.addNestedButton) - nestedStrip.addWidget(self.addNestedButton) - - self.entriesNote = QtWidgets.QLabel(entriesPane) - self.entriesNote.setWordWrap(True) - - entriesLayout.addWidget(self.entriesLabel) - entriesLayout.addWidget(self.entriesView, 1) - entriesLayout.addLayout(entryStrip) - entriesLayout.addLayout(nestedStrip) - entriesLayout.addWidget(self.entriesNote) - - instancesPane = QtWidgets.QWidget(rightPane) - instancesLayout = QtWidgets.QVBoxLayout(instancesPane) - instancesLayout.setContentsMargins(0, 0, 0, 0) - - self.instancesLabel = QtWidgets.QLabel(instancesPane) - self.instancesModel = QtGui.QStandardItemModel(0, 4, self) - self.instancesModel.setHorizontalHeaderLabels( - ["instance", "parameters", "also", ""] - ) - self.instancesView = QtWidgets.QTreeView(instancesPane) - self.instancesView.setModel(self.instancesModel) - self.instancesView.setRootIsDecorated(False) - self.instancesView.setEditTriggers( - QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers - ) - self.instancesView.setAlternatingRowColors(True) - instancesHeader = self.instancesView.header() - instancesHeader.setSectionResizeMode( - 0, QtWidgets.QHeaderView.ResizeMode.Stretch - ) - for column, width in ( - (1, INSTANCES_COUNT_WIDTH), - (2, INSTANCES_ALSO_WIDTH), - (3, INSTANCES_BUTTON_WIDTH), - ): - instancesHeader.setSectionResizeMode( - column, QtWidgets.QHeaderView.ResizeMode.Fixed - ) - instancesHeader.resizeSection(column, width) - - instanceStrip = QtWidgets.QHBoxLayout() - instanceStrip.setContentsMargins(0, 0, 0, 0) - instanceStrip.addWidget(QtWidgets.QLabel("New instance:")) - self.newInstanceEdit = QtWidgets.QLineEdit(instancesPane) - self.newInstanceEdit.setPlaceholderText("q04") - instanceStrip.addWidget(self.newInstanceEdit, 1) - self.addInstanceButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/plus-square.svg"), "Add instance" - ) - keepSmallHorizontally(self.addInstanceButton) - instanceStrip.addWidget(self.addInstanceButton) - self.instancesNote = QtWidgets.QLabel(instancesPane) - - instancesLayout.addWidget(self.instancesLabel) - instancesLayout.addWidget(self.instancesView, 1) - instancesLayout.addLayout(instanceStrip) - instancesLayout.addWidget(self.instancesNote) - - rightPane.addWidget(entriesPane) - rightPane.addWidget(instancesPane) - rightPane.setStretchFactor(0, 3) - rightPane.setStretchFactor(1, 2) - self.splitter.addWidget(typeListPane) - self.splitter.addWidget(rightPane) - self.splitter.setStretchFactor(0, 2) - self.splitter.setStretchFactor(1, 5) - layout.addWidget(self.splitter) - self.setLayout(layout) - - self.addTypeButton.clicked.connect(self._request_add_type) - self.newTypeEdit.returnPressed.connect(self.addTypeButton.click) - self.addEntryButton.clicked.connect(self._request_add_entry) - for edit in (self.entryNameEdit, self.entryDefaultEdit, self.entryUnitEdit): - edit.returnPressed.connect(self.addEntryButton.click) - self.addNestedButton.clicked.connect(self._request_add_nested) - self.nestedAtEdit.returnPressed.connect(self.addNestedButton.click) - self.addInstanceButton.clicked.connect(self._request_add_instance) - self.newInstanceEdit.returnPressed.connect(self.addInstanceButton.click) - self.typeList.selectionModel().currentChanged.connect(self._on_type_selected) - - # ------------------------------------------------------------------ - # rebuilds (plan task 5.5, readings 2-4, 7-8) - # ------------------------------------------------------------------ - - def select_type(self, name: str) -> None: - """Request the selection of the Type ``name``: honoured on the - next rebuild, once the Type is in the state the pane rebuilds - from. Used after the Server call that creates the Type.""" - self.requestedType = name - - def rebuild( - self, - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], - palette: TypePalette, - ) -> None: - """Rebuild the three panes from the client-side state (plan task - 5.5): the Type list from ``types`` with Instances counted over - ``parameters``, the entries and Instances panes from the selected - Type, and every row tinted with ``palette``.""" - self._rebuild_type_list(types, parameters, palette) - self._rebuild_selected_panes(types, parameters, palette) - - def _rebuild_type_list( - self, - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], - palette: TypePalette, - ) -> None: - names = list(types) - selection_changed = False - if self.requestedType is not None and self.requestedType in types: - selection_changed = self.selectedType != self.requestedType - self.selectedType = self.requestedType - self.requestedType = None - elif self.selectedType not in types: - # the first Type is selected when none is; the selection is - # dropped when the Type is gone - selection_changed = self.selectedType != (names[0] if names else None) - self.selectedType = names[0] if names else None - self.typeModel.removeRows(0, self.typeModel.rowCount()) - current_row = -1 - for row, name in enumerate(names): - count = len(instances_of_type(name, types, parameters)) - name_item = QtGui.QStandardItem(name) - instances_item = QtGui.QStandardItem(str(count)) - params_item = QtGui.QStandardItem(str(len(types[name].effective))) - self.typeModel.appendRow([name_item, instances_item, params_item]) - colours = palette.colours(name) - if colours is not None: - for item in (name_item, instances_item, params_item): - item.setData( - colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole - ) - if name == self.selectedType: - current_row = row - self._building = True - if current_row >= 0: - self.typeList.setCurrentIndex(self.typeModel.index(current_row, 0)) - else: - self.typeList.setCurrentIndex(QtCore.QModelIndex()) - self._building = False - if selection_changed and self.selectedType is not None: - self.typeSelected.emit(self.selectedType) - - def _rebuild_selected_panes( - self, - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], - palette: TypePalette, - ) -> None: - selected = self.selectedType or "" - # with no Type selected the labels keep no trailing space and the - # three strips are disabled — their actions all need a Type - # (plan task 5.6) - has_type = bool(selected) - self.entriesLabel.setText( - f"parameters of {selected}" if has_type else "parameters" - ) - self.instancesLabel.setText( - f"instances of {selected}" if has_type else "instances" - ) - self.addEntryButton.setEnabled(has_type) - self.addNestedButton.setEnabled(has_type) - self.addInstanceButton.setEnabled(has_type) - self._rebuild_nested_combo(selected, types) - self._rebuild_entries(selected, types, palette) - self._rebuild_instances(selected, types, parameters, palette) - - def _rebuild_nested_combo( - self, selected: str, types: Mapping[str, PMTypeBluePrint] - ) -> None: - self.nestedTypeCombo.clear() - self.nestedTypeCombo.addItems( - sorted(name for name in types if name != selected) - ) - - def _clear_index_widgets( - self, parent: Optional[QtGui.QStandardItem] = None - ) -> None: - """Delete the row widgets the entries view still hosts, so a - rebuild does not leave the old ones behind.""" - if parent is None: - parent = self.entriesModel.invisibleRootItem() - for row in range(parent.rowCount()): - for column in range(parent.columnCount()): - child = parent.child(row, column) - if child is None: - continue - widget = self.entriesView.indexWidget( - self.entriesModel.indexFromItem(child) - ) - if widget is not None: - widget.deleteLater() - first = parent.child(row, 0) - if first is not None and first.hasChildren(): - self._clear_index_widgets(first) - - def _entry_tint_type( - self, rows: List[EntryRow], index: int, selected: str - ) -> str: - """The Type whose tint an entries row shows: an entry row its - defining Type, a Nested Type row the Type required there, and a - structural submodule row the defining Type of the first entry - below it (the selected Type when that entry is own; the mock's - ``tintsFor(inc ? inc.type : (p.from || selType))``).""" - row = rows[index] - if row.kind == "entry": - return row.from_type or selected - if row.nested_type is not None: - return row.nested_type - for later in rows[index + 1:]: - if later.kind == "entry": - return later.from_type or selected - return selected - - def _rebuild_entries( - self, selected: str, types: Mapping[str, PMTypeBluePrint], palette: TypePalette - ) -> None: - self._clear_index_widgets() - self.entriesModel.removeRows(0, self.entriesModel.rowCount()) - self.entryWidgets = {} - blueprint = types.get(selected) - if selected is None or blueprint is None: - self.entriesView.expandAll() - return - rows = type_entry_rows(selected, types, self.instrument_name) - items_by_path: Dict[str, QtGui.QStandardItem] = {} - for index, row in enumerate(rows): - path = row.path - parent_item = ( - items_by_path[path.rsplit(".", 1)[0]] - if "." in path - else self.entriesModel.invisibleRootItem() - ) - colours = palette.colours(self._entry_tint_type(rows, index, selected)) - name_item = QtGui.QStandardItem(path.split(".")[-1]) - unit_item = QtGui.QStandardItem("" if row.kind == "submodule" else row.unit) - lock_item = QtGui.QStandardItem() - default_item = QtGui.QStandardItem() - parent_item.appendRow([name_item, unit_item, lock_item, default_item]) - items_by_path[path] = name_item - if colours is not None: - for item in (name_item, unit_item, lock_item, default_item): - item.setData( - colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole - ) - entry: Dict[str, Any] = { - "editor": None, - "set": None, - "remove": None, - "toggle": None, - "retarget": None, - "targetLabel": None, - "definedBy": None, - "removeNested": None, - } - self.entryWidgets[path] = entry - if row.kind == "submodule": - self._build_submodule_row( - selected, blueprint, row, lock_item, default_item, entry - ) - else: - self._build_entry_row( - selected, row, lock_item, default_item, entry - ) - self.entriesView.expandAll() - - def _build_submodule_row( - self, - selected: str, - blueprint: PMTypeBluePrint, - row: EntryRow, - lock_item: QtGui.QStandardItem, - default_item: QtGui.QStandardItem, - entry: Dict[str, Any], - ) -> None: - if row.nested_type is not None: - lock_item.setText(f"type: {row.nested_type}") - if row.path in blueprint.nested: - # only the selected Type's OWN Nested Types are removable - nested = blueprint.nested[row.path] - remove = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/delete.svg"), "", parent=self.entriesView.viewport() - ) - remove.setStyleSheet("QPushButton { background-color: salmon }") - remove.setToolTip( - f"stop requiring {nested} here — Instances keep the parameters" - ) - keepSmallHorizontally(remove) - remove.pressed.connect( - lambda type_name=selected, submodule=row.path: self.removeNestedRequested.emit( - type_name, submodule - ) - ) - self.entriesView.setIndexWidget( - self.entriesModel.indexFromItem(default_item), remove - ) - entry["removeNested"] = remove - - def _build_entry_row( - self, - selected: str, - row: EntryRow, - lock_item: QtGui.QStandardItem, - default_item: QtGui.QStandardItem, - entry: Dict[str, Any], - ) -> None: - if row.own: - self._build_own_entry_cells(selected, row, lock_item, default_item, entry) - else: - self._build_nested_entry_cells(selected, row, default_item, entry) - - def _build_own_entry_cells( - self, - selected: str, - row: EntryRow, - lock_item: QtGui.QStandardItem, - default_item: QtGui.QStandardItem, - entry: Dict[str, Any], - ) -> None: - # the "locked to" column: the Type Lock toggle, and while the entry - # is locked the re-target button and the Target's relative path - locked = row.target is not None - lock_container = QtWidgets.QWidget(self.entriesView.viewport()) - lock_layout = QtWidgets.QHBoxLayout(lock_container) - lock_layout.setContentsMargins(0, 0, 0, 0) - toggle = make_lock_button(lock_container, locked) - if locked: - toggle.setToolTip( - f"locked to {row.target} — unlock and every Instance of " - f"{selected} goes back to its own value" - ) - else: - toggle.setToolTip( - f"lock — _globals.{selected}.{row.path} is created to hold the " - f"value, and every Instance of {selected} follows it" - ) - toggle.pressed.connect( - lambda type_name=selected, path=row.path: self.toggleTypeLockRequested.emit( - type_name, path - ) - ) - lock_layout.addWidget(toggle) - entry["toggle"] = toggle - if locked: - retarget = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/set.svg"), "", parent=lock_container - ) - retarget.setToolTip( - f"lock every Instance of {selected} to another Target — " - "pick one in the parameter tree" - ) - keepSmallHorizontally(retarget) - retarget.pressed.connect( - lambda type_name=selected, path=row.path: self.retargetTypeLockRequested.emit( - type_name, path - ) - ) - lock_layout.addWidget(retarget) - entry["retarget"] = retarget - target_label = QtWidgets.QLabel(row.target, parent=lock_container) - target_label.setToolTip( - f"{row.target} — followed by every Instance of {selected}" - ) - lock_layout.addWidget(target_label, 1) - entry["targetLabel"] = target_label - self.entriesView.setIndexWidget( - self.entriesModel.indexFromItem(lock_item), lock_container - ) - - # the default column: the editable default with its set button and - # the entry's Remove button - editor_container = QtWidgets.QWidget(self.entriesView.viewport()) - editor_layout = QtWidgets.QHBoxLayout(editor_container) - editor_layout.setContentsMargins(0, 0, 0, 0) - editor = QtWidgets.QLineEdit(editor_container) - editor.setText("" if row.default is None else str(row.default)) - editor.setPlaceholderText("no default") - set_button = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/set.svg"), "", parent=editor_container - ) - keepSmallHorizontally(set_button) - set_button.pressed.connect( - lambda: self.setDefaultRequested.emit( - selected, row.path, editor.text() - ) - ) - editor.returnPressed.connect(set_button.click) - remove = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/delete.svg"), "", parent=editor_container - ) - remove.setStyleSheet("QPushButton { background-color: salmon }") - remove.setToolTip( - "remove from the Type only — Instances keep the parameter " - "and lose the Type tint" - ) - keepSmallHorizontally(remove) - remove.pressed.connect( - lambda type_name=selected, path=row.path: self.removeEntryRequested.emit( - type_name, path - ) - ) - editor_layout.addWidget(editor, 1) - editor_layout.addWidget(set_button) - editor_layout.addWidget(remove) - self.entriesView.setIndexWidget( - self.entriesModel.indexFromItem(default_item), editor_container - ) - entry["editor"] = editor - entry["set"] = set_button - entry["remove"] = remove - - def _build_nested_entry_cells( - self, - selected: str, - row: EntryRow, - default_item: QtGui.QStandardItem, - entry: Dict[str, Any], - ) -> None: - # read-only default text, and "defined by " where the Remove - # button of an own entry would sit - container = QtWidgets.QWidget(self.entriesView.viewport()) - layout = QtWidgets.QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - default_label = QtWidgets.QLabel( - "" if row.default is None else str(row.default), parent=container - ) - layout.addWidget(default_label, 1) - defined_by = QtWidgets.QLabel(f"defined by {row.from_type}", parent=container) - defined_by.setToolTip( - f"defined by {row.from_type} — change the default there" - ) - layout.addWidget(defined_by) - self.entriesView.setIndexWidget( - self.entriesModel.indexFromItem(default_item), container - ) - entry["definedBy"] = defined_by - - def _rebuild_instances( - self, - selected: str, - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], - palette: TypePalette, - ) -> None: - self.instancesModel.removeRows(0, self.instancesModel.rowCount()) - self.showButtons = {} - if not selected: - return - colours = palette.colours(selected) - for instance in instances_of_type(selected, types, parameters): - count = sum( - 1 for path in parameters if path.startswith(f"{instance}.") - ) - also = [ - type_name - for type_name in also_types(instance, types, parameters) - if type_name != selected - ] - name_item = QtGui.QStandardItem(instance) - count_item = QtGui.QStandardItem(f"{count} parameters") - also_item = QtGui.QStandardItem( - f"also {', '.join(also)}" if also else "" - ) - button_item = QtGui.QStandardItem() - self.instancesModel.appendRow( - [name_item, count_item, also_item, button_item] - ) - if colours is not None: - for item in (name_item, count_item, also_item, button_item): - item.setData( - colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole - ) - show = QtWidgets.QPushButton( - "Show", parent=self.instancesView.viewport() - ) - show.setToolTip("show in the parameter tree") - show.pressed.connect( - lambda type_name=selected, node=instance: self.showInstanceRequested.emit( - type_name, node - ) - ) - self.instancesView.setIndexWidget( - self.instancesModel.indexFromItem(button_item), show - ) - self.showButtons[instance] = show - - # ------------------------------------------------------------------ - # selection and strip requests (plan task 5.5, readings 3 and 5) - # ------------------------------------------------------------------ - - @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) - def _on_type_selected( - self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex - ) -> None: - """A row click selects the Type for the other two panes.""" - if self._building or not current.isValid(): - return - name = self.typeModel.item(current.row(), 0) - if name is None or name.text() == self.selectedType: - return - self.selectedType = name.text() - self.requestedType = None - self.typeSelected.emit(name.text()) - - def refresh_selected_panes( - self, - types: Mapping[str, PMTypeBluePrint], - parameters: Mapping[str, str], - palette: TypePalette, - ) -> None: - """Rebuild only the entries and Instances panes, keeping the Type - list as it is: the slot of a user selection.""" - self._rebuild_selected_panes(types, parameters, palette) - - @QtCore.Slot() - def _request_add_type(self) -> None: - name = self.newTypeEdit.text().strip() - if not name: - self.show_type_error("Name must not be empty.") - return - self.addTypeRequested.emit(name) - - @QtCore.Slot() - def _request_add_entry(self) -> None: - path = self.entryNameEdit.text().strip() - if not path: - self.show_entries_error("Name must not be empty.") - return - if self.selectedType is None: - return - # the unit is stripped: matching compares units exactly (D12), and - # a trailing space would make the Type match no Instance - self.addEntryRequested.emit( - self.selectedType, - path, - self.entryDefaultEdit.text(), - self.entryUnitEdit.text().strip(), - ) - - @QtCore.Slot() - def _request_add_nested(self) -> None: - submodule = self.nestedAtEdit.text().strip() - if not submodule: - self.show_entries_error("Submodule must not be empty.") - return - if self.selectedType is None: - return - self.addNestedRequested.emit( - self.selectedType, submodule, self.nestedTypeCombo.currentText() - ) - - @QtCore.Slot() - def _request_add_instance(self) -> None: - name = self.newInstanceEdit.text().strip() - if not name: - self.show_instances_error("Name must not be empty.") - return - if self.selectedType is None: - return - self.addInstanceRequested.emit(self.selectedType, name) - - # ------------------------------------------------------------------ - # note lines (plan task 5.5, reading 5) - # ------------------------------------------------------------------ - - def show_type_error(self, text: str) -> None: - """Show an action error in red on the New type strip's note.""" - self.typeNote.setStyleSheet("QLabel { color: red }") - self.typeNote.setText(text) - - def reset_type_note(self) -> None: - """Restore the New type strip's default note (empty).""" - self.typeNote.setStyleSheet("") - self.typeNote.setText("") - - def show_entries_error(self, text: str) -> None: - """Show an action error in red on the entries pane's note.""" - self.entriesNote.setStyleSheet("QLabel { color: red }") - self.entriesNote.setText(text) - - def show_entries_note(self, text: str) -> None: - """Show ``text`` on the entries pane's note in the normal colour - (a skipped-Lock warning, for example).""" - self.entriesNote.setStyleSheet("") - self.entriesNote.setText(text) - - def reset_entries_note(self) -> None: - """Restore the entries pane's default note (empty).""" - self.entriesNote.setStyleSheet("") - self.entriesNote.setText("") - - def show_instances_error(self, text: str) -> None: - """Show an action error in red on the instances pane's note.""" - self.instancesNote.setStyleSheet("QLabel { color: red }") - self.instancesNote.setText(text) - - def reset_instances_note(self) -> None: - """Restore the instances pane's default note (empty).""" - self.instancesNote.setStyleSheet("") - self.instancesNote.setText("") - - -# ----------------- Parameter Manager Types tab - Ending ------------------------------- - - -class ParameterDeleteDelegate(ParameterDelegate): - #: Signal(str) - #: Emits the name of the parameter to be deleted when the user presses the delete button. - removeParameter = QtCore.Signal(str) - - #: Signal(str) - #: Emits the name of the parameter whose lock button the user pressed; - #: the Parameter Manager GUI toggles that parameter's Lock. - toggleLock = QtCore.Signal(str) - - def createEditor( # type: ignore[override] - self, - widget: QtWidgets.QWidget, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, - ) -> QtWidgets.QWidget: - item = self.getItem(index) - - if not item.showDelegate: # type: ignore[attr-defined] - return None # type: ignore[return-value] - - element = item.element # type: ignore[attr-defined] - rw = self.makeRemoveWidget(item.name, widget) # type: ignore[attr-defined] - lw = self.make_lock_widget(item.name, widget) - - ret = ParameterWidget( - parameter=element, parent=widget, additionalWidgets=[lw, rw] - ) - # the lock button is kept on the row's ParameterWidget so the - # Parameter Manager GUI can restyle it with the Lock state - ret.lockButton = lw - self.parameters[item.name] = ret # type: ignore[attr-defined] - ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] - - if self.navFilter is not None: - if isinstance(ret.paramWidget, AnyInput): - input_widget = ret.paramWidget.input - else: - input_widget = ret.paramWidget - input_widget.installEventFilter(self.navFilter) - self.navFilter.registerWidget(input_widget, index) - - return ret - - def make_lock_widget( - self, fullName: str, widget: QtWidgets.QWidget - ) -> QtWidgets.QPushButton: - """The per-row lock button. It stays hidden until the row carries a - Lock (a Lock-less row shows no button, as the mock), fills purple - while the Lock is locked, and only :meth:`ParameterManagerGui. - apply_locks` changes its state.""" - w = make_lock_button(widget, locked=False) - w.setVisible(False) - - w.pressed.connect(lambda: self.toggleLock.emit(fullName)) - return w - - def makeRemoveWidget( - self, fullName: str, widget: QtWidgets.QWidget - ) -> QtWidgets.QPushButton: - w = QtWidgets.QPushButton(QtGui.QIcon(":/icons/delete.svg"), "", parent=widget) - w.setStyleSheet(""" - QPushButton { background-color: salmon } - """) - w.setToolTip("Delete this parameter") - keepSmallHorizontally(w) - - w.pressed.connect(lambda: self.removeParameter.emit(fullName)) - return w - - -# TODO: Make sure that the refresh button refreshes the profiles as well as the model -class ParameterManagerTreeView(InstrumentTreeViewBase): - #: Signal(str) - #: Emitted when the user picks "Lock to…" in the context menu; the - #: Parameter Manager GUI arms the target picker for that parameter. - lockToRequested = QtCore.Signal(str) - - #: Signal(str) - #: Emitted when the user picks "Unlock" in the context menu. - unlockRequested = QtCore.Signal(str) - - def __init__( - self, - model: QtCore.QAbstractItemModel, - *args: Any, - **kwargs: Any, - ) -> None: - super().__init__(model, [2], *args, **kwargs) - - self.delegate = ParameterDeleteDelegate(self) - self.delegate.navFilter = ValueCellNavigationFilter(self) - - self.setItemDelegateForColumn(2, self.delegate) - - # the gutter column exists only in the Parameter Manager's own model - # (ModelParameterManager) - self.gutterDelegate = GutterDelegate(self) - if self.model().columnCount() > GUTTER_COLUMN: - self.setItemDelegateForColumn(GUTTER_COLUMN, self.gutterDelegate) - header = self.header() - # the gutter moves to visual position 0 with a fixed width; the - # tree branches stay on the name column - header.moveSection(GUTTER_COLUMN, 0) - if header.minimumSectionSize() > GUTTER_WIDTH: - header.setMinimumSectionSize(GUTTER_WIDTH) - header.setSectionResizeMode( - GUTTER_COLUMN, QtWidgets.QHeaderView.ResizeMode.Fixed - ) - header.resizeSection(GUTTER_COLUMN, GUTTER_WIDTH) - if self.model().columnCount() > LOCK_COLUMN: - # the Lock column moves between the unit and the delegate - # column, with a resizable default width - header.moveSection( - header.visualIndex(LOCK_COLUMN), header.visualIndex(2) - ) - header.setSectionResizeMode( - LOCK_COLUMN, QtWidgets.QHeaderView.ResizeMode.Interactive - ) - header.resizeSection(LOCK_COLUMN, LOCK_COLUMN_WIDTH) - self.setTreePosition(0) - self.setAllDelegatesPersistent() - - # the lock actions act on the row the context menu was opened for - # (self.lastSelectedItem, set by the base onContextMenuRequested - # before the menu opens); the Parameter Manager GUI enables and - # disables them in its aboutToShow slot - self.lockToAction = QtWidgets.QAction("Lock to…") - self.lockToAction.triggered.connect(self._on_lock_to_action_trigger) - self.unlockAction = QtWidgets.QAction("Unlock") - self.unlockAction.triggered.connect(self._on_unlock_action_trigger) - self.contextMenu.addSeparator() - self.contextMenu.addAction(self.lockToAction) - self.contextMenu.addAction(self.unlockAction) - - @QtCore.Slot() - def _on_lock_to_action_trigger(self) -> None: - """The context menu's "Lock to…": arm the target picker for the - row's parameter; a submodule row has no Lock to arm.""" - item = self.lastSelectedItem - if item is not None and item.element is not None: - self.lockToRequested.emit(item.name) - - @QtCore.Slot() - def _on_unlock_action_trigger(self) -> None: - """The context menu's "Unlock": unlock the row's Lock; a submodule - row has no Lock to unlock.""" - item = self.lastSelectedItem - if item is not None and item.element is not None: - self.unlockRequested.emit(item.name) - - @QtCore.Slot(object, object) - def onItemNewValue(self, itemName: str, value: Any) -> None: - widget = self.delegate.parameters[itemName] - try: - # use the abstract set method defined in parameter widget so it works for different types of widgets - widget._setMethod(value) - except RuntimeError: - logger.debug( - f"Could not set value for {itemName} to {value}. Object is not being shown right now." - ) - - -class ProfilesManager(QtWidgets.QComboBox): - #: Signal() - #: Emitted when the selected index changed. - indexChanged = QtCore.Signal() - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - self.setEditable(False) - self.params = self.parent().instrument # type: ignore[union-attr] - self.refreshing = False - - loadingProfile = None - for profile in self.params.list_profiles(): - self.addItem(self.params.cleanProfileName(profile)) - if loadingProfile is None: - loadingProfile = profile - - self.currentIndexChanged.connect(self.onCurrentIndexChanged) - - def refresh(self) -> None: - self.refreshing = True - currentlySelected = self.currentText() - self.clear() - for profile in self.params.list_profiles(): - self.addItem(self.params.cleanProfileName(profile)) - if self.params.cleanProfileName(profile) == currentlySelected: - self.setCurrentIndex(self.count() - 1) - self.refreshing = False - - @QtCore.Slot(int) - def onCurrentIndexChanged(self, index: int) -> None: - if not self.refreshing: - self.indexChanged.emit() - - -class PMState: - """Client-side cache of a Parameter Manager's Types and Locks. - - The Parameter Manager GUI owns one instance (``ParameterManagerGui.state``) - so its widgets can react to Types and Locks without querying the Server - again. It starts empty and is filled from the Parameter Manager — a Proxy - Instrument or a local one — with :meth:`refresh`; the ``pm-lock-update`` - and ``pm-type-update`` Broadcasts then keep single entries current through - :meth:`apply_lock` and :meth:`apply_type` (D22). - - ``types`` maps each Type's name to its :class:`PMTypeBluePrint`; ``locks`` - maps each Follower's path relative to the Parameter Manager — the form - ``list_locks()`` returns — to its :class:`PMLockBluePrint`. - """ - - def __init__(self) -> None: - self.types: Dict[str, PMTypeBluePrint] = {} - self.locks: Dict[str, PMLockBluePrint] = {} - - def refresh(self, instrument: Any) -> None: - """Re-read every Type and Lock from the Parameter Manager. - - Works with a Proxy Instrument and with a local Parameter Manager: - both expose ``list_types``, ``get_type`` and ``list_locks``. - - :param instrument: the Parameter Manager whose Types and Locks to - read. - """ - self.types = { - type_name: instrument.get_type(type_name) - for type_name in instrument.list_types() - } - self.locks = dict(instrument.list_locks()) - - def apply_lock(self, path: str, lock: Optional[PMLockBluePrint]) -> None: - """Record the change a ``pm-lock-update`` Broadcast reports about - the Follower at ``path``. - - :param path: the Follower's path relative to the Parameter Manager. - :param lock: the Follower's :class:`PMLockBluePrint`, or ``None`` - when its Lock was removed (the entry is dropped then). - """ - if lock is None: - self.locks.pop(path, None) - else: - self.locks[path] = lock - - def apply_type(self, name: str, type_blueprint: Optional[PMTypeBluePrint]) -> None: - """Record the change a ``pm-type-update`` Broadcast reports about - the Type ``name``. - - :param name: the Type's name. - :param type_blueprint: the Type's :class:`PMTypeBluePrint`, or - ``None`` when the Type was removed (the entry is dropped then). - """ - if type_blueprint is None: - self.types.pop(name, None) - else: - self.types[name] = type_blueprint - - -class ParameterManagerGui(InstrumentParameters): - #: Signal(str) -- - #: emitted when there's an error during parameter creation. - parameterCreationError = QtCore.Signal(str) - - #: Signal() -- - #: emitted when a parameter was created successfully - parameterCreated = QtCore.Signal() - - def __init__( - self, - instrument: Union[ProxyInstrument, ParameterManager], - parent: Optional[QtWidgets.QWidget] = None, - **kwargs: Any, - ) -> None: - super().__init__( - instrument, - parent=None, - viewType=ParameterManagerTreeView, - callSignals=False, - modelType=ModelParameterManager, - **kwargs, - ) - # The client-side cache of the Parameter Manager's Types and Locks. - # Created before connectSignals, which wires the model's Broadcast - # routing into it. - self.state = PMState() - # The tint palette: maps each Type to its slot in TINT_PALETTE; the - # view's gutter delegate reads the colours from it. - self.typePalette = TypePalette() - self.view.gutterDelegate.typePalette = self.typePalette - self.profileManager = ProfilesManager(parent=self) - self.addParam = AddParameterWidget(parent=self) - layout = self.layout() - assert isinstance(layout, QtWidgets.QVBoxLayout) - layout.insertWidget(0, self.profileManager) - layout.addWidget(self.addParam) - # The Locks panel (plan task 5.4) sits right of the tree in a - # splitter: the view keeps its identity, so every existing layout - # consumer and test keeps working. The panel starts hidden and - # costs nothing until the toolbar action shows it. - self.locksPanel = LocksPanel(self.instrument.name, parent=self) - view_index = layout.indexOf(self.view) - layout.removeWidget(self.view) - self.locksSplitter = QtWidgets.QSplitter( - QtCore.Qt.Orientation.Horizontal, self - ) - self.locksSplitter.addWidget(self.view) - self.locksSplitter.addWidget(self.locksPanel) - self.locksSplitter.setStretchFactor(0, 3) - self.locksSplitter.setStretchFactor(1, 2) - layout.insertWidget(view_index, self.locksSplitter) - self.locksPanel.setVisible(False) - # The existing content becomes tab 0 of the tab widget; tab 1 - # holds the Types pane (plan task 5.5). - self.parametersTab = QtWidgets.QWidget(self) - self.parametersTab.setLayout(self.layout()) - self.typesTab = QtWidgets.QWidget(self) - typesTabLayout = QtWidgets.QVBoxLayout(self.typesTab) - typesTabLayout.setContentsMargins(0, 0, 0, 0) - self.typesPane = TypesPane(self.instrument.name, parent=self.typesTab) - typesTabLayout.addWidget(self.typesPane) - self.tabs = QtWidgets.QTabWidget(self) - self.tabs.addTab(self.parametersTab, "Parameters") - self.tabs.addTab(self.typesTab, "Types") - outerLayout = QtWidgets.QVBoxLayout(self) - outerLayout.setContentsMargins(0, 0, 0, 0) - outerLayout.addWidget(self.tabs) - # The arm strip sits right under the toolbar and stays hidden until - # a Lock's Target is being picked (plan task 5.3). The Follower the - # pick is armed for is kept here, and — for the Types tab's Type - # Lock re-target (plan task 5.5) — the (Type, entry) pair the - # re-target is armed for. - self.armed_follower: Optional[str] = None - self.armed_type_lock: Optional[Tuple[str, str]] = None - self.armStrip = LockArmStrip(self.parametersTab) - parametersLayout = self.parametersTab.layout() - assert isinstance(parametersLayout, QtWidgets.QVBoxLayout) - toolbar_index = parametersLayout.indexOf(self.toolbar) - parametersLayout.insertWidget(toolbar_index + 1, self.armStrip) - self.armStrip.setVisible(False) - # Escape over the tree cancels the pick too (harmless when the - # strip is not armed) - self.viewEscShortcut = QtWidgets.QShortcut( - QtGui.QKeySequence("Escape"), self.view - ) - self.viewEscShortcut.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) - self.viewEscShortcut.activated.connect(self.cancel_arm) - # The confirmation dialog for removing a Lock Target (plan task - # 5.6), kept on the GUI so tests can drive it; ``None`` while no - # removal that needs one is in flight. - self.removalDialog: Optional[QtWidgets.QMessageBox] = None - self.connectSignals() - self.loadProfile() - - def connectSignals(self) -> None: - super().connectSignals() - self.view.delegate.removeParameter.connect(self.removeParameter) - self.view.delegate.toggleLock.connect(self._toggle_lock) - self.addParam.newParamRequested.connect(self.addParameter) - self.parameterCreationError.connect(self.addParam.setError) - self.parameterCreated.connect(self.addParam.clear) - self.profileManager.indexChanged.connect(self.loadProfile) - self.model.lockChanged.connect(self._on_lock_changed) - self.model.typeChanged.connect(self._on_type_changed) - self.model.structureChanged.connect(self.apply_tints) - self.model.structureChanged.connect(self.apply_locks) - self.model.itemNewValue.connect(self._on_item_new_value) - # the filter (and the trash toggle) hides rows; when they come - # back, restoreCollapsedDict has re-opened their persistent - # editors, so createEditor has built fresh ParameterWidgets whose - # lock button is hidden and whose input is editable — re-apply the - # Lock state to them - self.proxyModel.filterFinished.connect(self.apply_locks) - self.view.lockToRequested.connect(self.arm_lock) - self.view.unlockRequested.connect(self._unlock) - self.view.contextMenu.aboutToShow.connect(self._update_lock_actions) - self.view.clicked.connect(self._on_view_clicked) - self.armStrip.targetPicked.connect(self.pick_lock_target) - self.armStrip.cancelled.connect(self.cancel_arm) - # the Locks panel (plan task 5.4): its actions run through this GUI, - # and the tree's current row drives the panel's selected label - self.locksAction.toggled.connect(self._on_locks_action_toggled) - self.locksPanel.toggleLockRequested.connect(self._on_panel_toggle_lock) - self.locksPanel.removeLockRequested.connect(self._on_panel_remove_lock) - self.locksPanel.lockAllRequested.connect(self._on_panel_lock_all) - self.locksPanel.removeRuleRequested.connect(self._on_panel_remove_rule) - self.locksPanel.lockSelectionRequested.connect( - self._lock_selection_from_panel - ) - self.view.selectionModel().currentChanged.connect( - self._on_tree_current_changed - ) - # the Types pane (plan task 5.5): its actions run through this GUI, - # and a selection change re-renders the two panes it drives - self.typesPane.typeSelected.connect(self._on_pane_type_selected) - self.typesPane.addTypeRequested.connect(self._on_pane_add_type) - self.typesPane.addEntryRequested.connect(self._on_pane_add_entry) - self.typesPane.removeEntryRequested.connect(self._on_pane_remove_entry) - self.typesPane.setDefaultRequested.connect(self._on_pane_set_default) - self.typesPane.toggleTypeLockRequested.connect( - self._on_pane_toggle_type_lock - ) - self.typesPane.retargetTypeLockRequested.connect(self.arm_type_lock) - self.typesPane.addNestedRequested.connect(self._on_pane_add_nested) - self.typesPane.removeNestedRequested.connect(self._on_pane_remove_nested) - self.typesPane.addInstanceRequested.connect(self._on_pane_add_instance) - self.typesPane.showInstanceRequested.connect(self._on_pane_show_instance) - self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) - self.shortcutManager.register("clear_add", self.addParam.clear, self) - self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) - self.shortcutManager.register("load_items", self.loadFromFile, self) - self.shortcutManager.register("save_items", self.saveToFile, self) - self.shortcutManager.register("toggle_locks", self.locksAction.toggle, self) - # the Lock shortcuts (plan task 5.6); the tree's two lock actions - # carry their key in their tooltips - self.shortcutManager.register("lock_to", self._lock_current_item, self) - self.shortcutManager.register("unlock_item", self._unlock_current_item, self) - self.shortcutManager.register("show_types", self._toggle_tabs, self) - self.shortcutManager.register_tooltip("lock_to", self.view.lockToAction) - self.shortcutManager.register_tooltip("unlock_item", self.view.unlockAction) - - @QtCore.Slot() - def _deleteCurrentItem(self) -> None: - item = self._getCurrentItem() - if item is not None: - self.removeParameter(item.name) - - def makeToolbar(self) -> QtWidgets.QToolBar: - toolbar = super().makeToolbar() - - toolbar.addSeparator() - - loadParamAction = toolbar.addAction( - QtGui.QIcon(":/icons/load.svg"), - "Load parameters from file", - ) - loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] - self.shortcutManager.register_tooltip("load_items", loadParamAction) - - saveParamAction = toolbar.addAction( - QtGui.QIcon(":/icons/save.svg"), - "Save parameters to file", - ) - saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] - self.shortcutManager.register_tooltip("save_items", saveParamAction) - - # the Locks panel toggle (plan task 5.4); the toggled connection - # and the shortcut are wired in connectSignals, where the panel - # exists - self.locksAction = toolbar.addAction( - QtGui.QIcon(":/icons/lock.svg"), - "Show the Locks panel", - ) - self.locksAction.setCheckable(True) - self.shortcutManager.register_tooltip("toggle_locks", self.locksAction) - - return toolbar - - def refreshAll(self) -> None: - super().refreshAll() - self.instrument.refresh_profiles() - self.profileManager.refresh() - self.state.refresh(self.instrument) - self.apply_tints() - self.apply_locks() - - def removeParameter(self, fullName: str) -> None: - """Remove the parameter at ``fullName`` (the row's delete button - and the delete_item shortcut both land here). - - While the parameter is the Target of Locks — deleting it drops - them (D3) — a QMessageBox names every Follower that will lose its - Lock and asks for confirmation (plan task 5.6); Cancel returns - without touching the Server. A parameter without Followers is - removed without a dialog.""" - self.removalDialog = None - if not self.instrument.has_param(fullName): - return - try: - followers = self.instrument.followers_of(fullName) - except Exception: - # the Server call failed: fall back to the client-side list - # computed from the state — locked and unlocked alike, the - # Targets compared through relative_path - followers = sorted( - follower - for follower, lock in self.state.locks.items() - if relative_path(lock.target, self.instrument.name) == fullName - ) - if followers: - lines = [] - for follower in followers: - lock = self.state.locks.get(follower) - state = "locked" if lock is not None and lock.locked else "unlocked" - lines.append(f"{follower} ({state})") - box = QtWidgets.QMessageBox(self) - box.setObjectName("removalDialog") - box.setIcon(QtWidgets.QMessageBox.Icon.Question) - box.setWindowTitle("Remove Target?") - # macOS ignores a QMessageBox's window title (windowTitle() - # reads back empty there); tests pin the dialog through its - # object name and text instead. - box.setText( - f"Removing {fullName} also removes the Locks of:\n" - + "\n".join(lines) - ) - box.setStandardButtons( - QtWidgets.QMessageBox.StandardButton.Ok - | QtWidgets.QMessageBox.StandardButton.Cancel - ) - box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) - self.removalDialog = box - clicked = box.exec() - # the box is closed on both paths: the attribute matches its - # docstring again (the tests' QTimer callbacks read it while - # the box is open, so they keep working) - self.removalDialog = None - if clicked != QtWidgets.QMessageBox.StandardButton.Ok: - return - self.instrument.remove_parameter(fullName) - - def addParameter(self, fullName: str, value: Any, unit: str) -> None: - try: - # Validators are commented out until they can be serialized. - self.instrument.add_parameter( - fullName, - initial_value=value, - unit=unit, - ) # vals=vals) - self.parameterCreated.emit() - except Exception as e: - self.parameterCreationError.emit( - f"Could not create parameter.Adding parameter raised{type(e)}: {e.args}" - ) - return - - @QtCore.Slot() - def loadProfile(self) -> None: - profileName = self.profileManager.currentText() - self.instrument.switch_to_profile(profileName) - super().refreshAll() - self.instrument.refresh_profiles() - # a profile load emits no parameter-creation/parameter-deletion - # Broadcasts for the parameters it (re)creates, so the state of the - # Types and Locks must be re-read from the Parameter Manager - self.state.refresh(self.instrument) - self.apply_tints() - self.apply_locks() - - @QtCore.Slot(str, object) - def _on_type_changed( - self, name: str, type_blueprint: Optional[PMTypeBluePrint] - ) -> None: - """Record the change a ``pm-type-update`` Broadcast reports about - the Type ``name`` in the state, then recompute the tints and gutter - bands it may change, and rebuild the Locks panel (its Type Lock - rows depend on the Types).""" - self.state.apply_type(name, type_blueprint) - self.apply_tints() - self.refresh_locks_panel() - - @QtCore.Slot(str, object) - def _on_lock_changed( - self, path: str, lock: Optional[PMLockBluePrint] - ) -> None: - """Record the change a ``pm-lock-update`` Broadcast reports about - the Follower at ``path``, then recompute the Lock column and the - row widgets, and repaint the values the change alters: the - Follower's own and every row whose chain of locked Locks reaches - it, since locking and unlocking change what ``get`` answers — in - the tree and, while it is shown, in the Locks panel.""" - self.state.apply_lock(path, lock) - self.apply_locks() - refreshed = [ - path, - *followers_reaching(path, self.state.locks, self.instrument.name), - ] - for follower in refreshed: - self._refresh_row_widget(follower) - if not self.locksPanel.isHidden(): - self.locksPanel.refresh_values(refreshed) - - @QtCore.Slot(object, object) - def _on_item_new_value(self, path: object, value: object) -> None: - """Repaint every Follower whose locked Lock chain reaches the - parameter a ``parameter-update`` Broadcast names (D3: a locked - Follower answers ``get`` with the Target's value, and the - Parameter Manager emits nothing for values). The Broadcast's own - row is refreshed by the base wiring to - ``view.onItemNewValue``; this slot handles the rows behind it — - and, while the Locks panel is shown, the same paths there.""" - followers = followers_reaching( - str(path), self.state.locks, self.instrument.name - ) - for follower in followers: - self._refresh_row_widget(follower) - if not self.locksPanel.isHidden(): - self.locksPanel.refresh_values([str(path), *followers]) - - def _refresh_row_widget(self, path: str) -> None: - """Re-read the parameter behind the row at ``path`` through the - Proxy, which pulls the Target's value for a locked Follower, and - show it on the row's widget.""" - widget = self.view.delegate.parameters.get(path) - if widget is None: - return - try: - widget.setWidgetFromParameter() - except RuntimeError: - logger.debug( - f"Could not refresh the value of {path}. " - "Object is not being shown right now." - ) - - @QtCore.Slot() - def apply_locks(self) -> None: - """Recompute every parameter row's Lock state from the client-side - state (plan task 5.3): the Lock column text, the lock button's - visibility, tooltip and purple fill, and whether the value renders - read-only. - - Runs after the state was refreshed from the Parameter Manager (on a - model reload), on every ``pm-lock-update`` Broadcast, and after a - parameter was created or removed by a Broadcast. Recomputing all - rows on every change is fine — the tree is small — and keeps one - clear path. The Locks panel is rebuilt with the same state at the - end, but only while it is shown.""" - self._apply_locks_to_rows(self.model.invisibleRootItem()) - self.refresh_locks_panel() - - def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: - """Walk the source model (never the proxy) and set each row's Lock - column text, lock button state and read-only flag.""" - for row in range(parent.rowCount()): - item = parent.child(row, 0) - if item is None: - continue - lockItem = parent.child(row, LOCK_COLUMN) - if lockItem is None: - lockItem = QtGui.QStandardItem() - parent.setChild(row, LOCK_COLUMN, lockItem) - if item.element is None: - # a submodule row carries no Lock state of its own - lockItem.setText("") - else: - lockItem.setText( - lock_column_text( - item.name, self.state.locks, self.instrument.name - ) - ) - widget = self.view.delegate.parameters.get(item.name) - if widget is not None: - self._update_row_lock_widget(item.name, widget) - if item.hasChildren(): - self._apply_locks_to_rows(item) - - def _update_row_lock_widget( - self, path: str, widget: "ParameterWidget" - ) -> None: - """Set one row's lock button and read-only state from the Lock the - state holds for ``path``. A row without a Lock shows no button and - renders its value editable.""" - button = getattr(widget, "lockButton", None) - lock = self.state.locks.get(path) - if lock is None: - if button is not None: - button.setVisible(False) - widget.set_read_only(False) - return - target = relative_path(lock.target, self.instrument.name) - tooltip = lock_button_tooltip(lock.locked, target) - if button is not None: - button.setToolTip(tooltip) - button.setProperty("locked", lock.locked) - # re-polish so the locked property restyles the button - button.style().unpolish(button) - button.style().polish(button) - button.setVisible(True) - widget.set_read_only(lock.locked) - - @QtCore.Slot(str) - def _toggle_lock(self, path: str) -> None: - """Toggle the Lock of the parameter at ``path`` (the row's lock - button). A refused toggle — relocking would close a cycle (D7) — - shows the Server's error text on the row's alert widget.""" - widget = self.view.delegate.parameters.get(path) - try: - self.instrument.toggle_lock(path) - except Exception as e: - if widget is not None: - widget.alertWidget.setAlert(str(e)) - - @QtCore.Slot(str) - def _unlock(self, path: str) -> None: - """Unlock the Lock of the parameter at ``path`` (the context - menu's "Unlock"). A refused unlock shows the Server's error text - on the row's alert widget.""" - widget = self.view.delegate.parameters.get(path) - try: - self.instrument.unlock(path) - except Exception as e: - if widget is not None: - widget.alertWidget.setAlert(str(e)) - - def _lock_current_item(self) -> None: - """The lock_to shortcut (plan task 5.6): arm the target picker for - the tree's current parameter row. A submodule row or no selection - does nothing.""" - item = self._getCurrentItem() - if item is not None and item.element is not None: - self.arm_lock(item.name) - - def _unlock_current_item(self) -> None: - """The unlock_item shortcut (plan task 5.6): unlock the Lock of - the tree's current parameter row while it is locked; a row - without a locked Lock does nothing. A refused unlock shows the - Server's error text on the row's alert widget, like the context - menu's Unlock.""" - item = self._getCurrentItem() - if item is None or item.element is None: - return - lock = self.state.locks.get(item.name) - if lock is None or not lock.locked: - return - self._unlock(item.name) - - def _toggle_tabs(self) -> None: - """The show_types shortcut (plan task 5.6): switch between the - Parameters and Types tabs.""" - self.tabs.setCurrentIndex(1 if self.tabs.currentIndex() == 0 else 0) - - @QtCore.Slot() - def _update_lock_actions(self) -> None: - """Enable the context menu's lock actions for the row the menu was - opened on: "Lock to…" for every parameter row, "Unlock" only for a - parameter whose Lock in the state is locked.""" - item = self.view.lastSelectedItem - is_parameter = item is not None and item.element is not None - self.view.lockToAction.setEnabled(is_parameter) - self.view.unlockAction.setEnabled( - is_parameter - and item.name in self.state.locks # type: ignore[union-attr] - and self.state.locks[item.name].locked # type: ignore[union-attr] - ) - - def arm_lock(self, follower: str) -> None: - """Arm the target picker for the Follower at ``follower``: the - candidates are every other parameter row of the source model, - ranked like the mock's completer (same relative path inside its - Instance first), and the strip shows under the toolbar. Arming - while already armed re-arms for the new Follower.""" - parameters = self._model_parameters() - claims = compute_claims(self.state.types, parameters) - self.armed_follower = follower - self.armed_type_lock = None - self.armStrip.arm(follower, rank_lock_targets(follower, parameters, claims)) - - def arm_type_lock(self, type_name: str, path: str) -> None: - """Arm the target picker for the Type Lock of the entry ``path`` - of the Type ``type_name`` (plan task 5.5): the strip shows on the - Parameters tab with the entry named in its label, and the - candidates are every parameter row of the source model ranked - with the entry path as the relative path (the same leaf on any - Instance first). Arming while already armed re-arms for the new - entry.""" - self.armed_type_lock = (type_name, path) - self.armed_follower = None - self.tabs.setCurrentIndex(0) - parameters = self._model_parameters() - claims = compute_claims(self.state.types, parameters) - self.armStrip.arm( - f"type {type_name} \u00b7 {path}", - rank_lock_targets("", parameters, claims, arm_rel=path), - ) - - def pick_lock_target(self, target: str) -> None: - """Pick ``target`` as the Target of the armed pick — a Follower's - Lock (plan task 5.3) or, while a Type Lock re-target is armed, the - Type Lock declaration with ``target`` as its Target (plan task - 5.5). A refused Lock — a cycle (D7), a self-lock — shows the - Server's error text on the strip and stays armed so another - target can be picked; a successful Lock disarms the strip, and a - Type Lock declaration names the Instance parameters it skipped - (D17) on the entries pane's note.""" - if self.armed_type_lock is not None: - type_name, path = self.armed_type_lock - try: - skipped = self.instrument.lock_type_parameter( - type_name, path, target=target - ) - except Exception as exc: - self.armStrip.show_error(str(exc)) - else: - if skipped: - self.typesPane.show_entries_note( - f"skipped: {', '.join(skipped)}" - ) - else: - # a clean declaration leaves no stale error or - # skipped note behind (plan task 5.6) - self.typesPane.reset_entries_note() - self.cancel_arm() - return - if self.armed_follower is None: - return - try: - self.instrument.lock(self.armed_follower, target) - except Exception as exc: - self.armStrip.show_error(str(exc)) - else: - self.cancel_arm() - - def cancel_arm(self) -> None: - """Disarm the target picker without picking anything (either kind - of pick: a Follower's Lock or a Type Lock's re-target).""" - self.armed_follower = None - self.armed_type_lock = None - self.armStrip.disarm() - - @QtCore.Slot(QtCore.QModelIndex) - def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: - """A row click while the pick is armed chooses that row's parameter - as the Target (the mock's rowClick); a submodule click does - nothing.""" - if self.armed_follower is None and self.armed_type_lock is None: - return - source_index = self.proxyModel.mapToSource(index) - source_index = source_index.sibling(source_index.row(), 0) - item = self.model.itemFromIndex(source_index) - if item is not None and item.element is not None: - self.pick_lock_target(item.name) - - # ------------------------------------------------------------------ - # the Locks panel (plan task 5.4) - # ------------------------------------------------------------------ - - @QtCore.Slot(bool) - def _on_locks_action_toggled(self, checked: bool) -> None: - """Show or hide the Locks panel with the toolbar action, and - rebuild its rows when it becomes visible (a hidden panel costs - nothing).""" - self.locksPanel.setVisible(checked) - if checked: - self.refresh_locks_panel() - - @QtCore.Slot() - def refresh_locks_panel(self) -> None: - """Rebuild the Locks panel's rows from the client-side state (plan - task 5.4): the rows from ``PMState.locks`` and ``PMState.types``, - each row's parameter resolved through the instrument. - - Runs at the end of :meth:`apply_locks` and of - :meth:`_on_type_changed` — the Type Lock rows depend on the Types — - and when the toolbar action shows the panel, but only while the - panel is shown, so a hidden panel costs nothing.""" - if self.locksPanel.isHidden(): - return - rows = build_lock_rows( - self.state.locks, self.state.types, self.instrument.name - ) - elements: Dict[str, Any] = {} - for path in _lock_row_paths(rows): - try: - elements[path] = nestedAttributeFromString(self.instrument, path) - except (AttributeError, RuntimeError) as exc: - logger.debug( - f"could not resolve the parameter of the Locks panel " - f"row {path}: {exc}" - ) - self.locksPanel.rebuild( - rows, elements, self.state.types, self.state.locks - ) - - @QtCore.Slot(str) - def _on_panel_toggle_lock(self, path: str) -> None: - """The Locks panel's lock/relock toggle: toggle the Lock of the - parameter at ``path``. A refused toggle shows the Server's error - text on the panel's note label.""" - try: - self.instrument.toggle_lock(path) - except Exception as exc: - self.locksPanel.show_error(str(exc)) - else: - self.locksPanel.reset_note() - - @QtCore.Slot(str) - def _on_panel_remove_lock(self, path: str) -> None: - """The Locks panel's remove button: remove the Lock of the - parameter at ``path``. A refused removal shows the Server's error - text on the panel's note label.""" - try: - self.instrument.remove_lock(path) - except Exception as exc: - self.locksPanel.show_error(str(exc)) - else: - self.locksPanel.reset_note() - - @QtCore.Slot(str, str, str) - def _on_panel_lock_all(self, type_name: str, entry: str, target: str) -> None: - """The Type Lock row's "lock all" button: declare the Type Lock - again with the entry's stored Target — called with ``target=None`` - the Server would re-point the rule to the Globals default (D17). - Instance parameters the declaration skips, because they carry a - Lock on another Target (D17), are named on the note label.""" - try: - skipped = self.instrument.lock_type_parameter( - type_name, entry, target=target - ) - except Exception as exc: - self.locksPanel.show_error(str(exc)) - else: - if skipped: - self.locksPanel.show_note(f"skipped: {', '.join(skipped)}") - else: - self.locksPanel.reset_note() - - @QtCore.Slot(str, str) - def _on_panel_remove_rule(self, type_name: str, entry: str) -> None: - """The Type Lock row's "remove rule" button: remove only the rule - (D17); the Locks it created stay until they are removed one by - one. A refused removal shows the Server's error text on the - panel's note label.""" - try: - self.instrument.unlock_type_parameter(type_name, entry) - except Exception as exc: - self.locksPanel.show_error(str(exc)) - else: - self.locksPanel.reset_note() - - @QtCore.Slot() - def _lock_selection_from_panel(self) -> None: - """The panel's "Lock selection to…": arm the target picker for the - tree's current parameter row. With no parameter row current, the - note label says so and nothing is armed; a successful arm clears a - stale error from the note (plan task 5.6).""" - item = self._getCurrentItem() - if item is None or item.element is None: - self.locksPanel.show_error("Select a parameter in the tree first.") - return - self.locksPanel.reset_note() - self.arm_lock(item.name) - - @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) - def _on_tree_current_changed( - self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex - ) -> None: - """Keep the panel's selected label on the tree's current row: a - parameter row shows its path, a submodule row or no selection shows - "no parameter selected".""" - item = self._getCurrentItem() - if item is not None and item.element is not None: - self.locksPanel.selectedLabel.setText(item.name) - else: - self.locksPanel.selectedLabel.setText("no parameter selected") - - @QtCore.Slot() - def apply_tints(self) -> None: - """Recompute every row's Type claims and repaint the tints and - gutter bands (plan task 5.2). - - Runs after the state was refreshed from the Parameter Manager (on a - model reload), on every ``pm-type-update`` Broadcast, and after a - parameter was created or removed by a Broadcast, since matching - depends on which parameters exist. The Types pane rebuilds from - the same state at the end (plan task 5.5). - """ - self.typePalette.sync(self.state.types) - claims = compute_claims(self.state.types, self._model_parameters()) - self._apply_tints_to_rows(self.model.invisibleRootItem(), claims) - self.refresh_types_pane() - - def _model_parameters(self) -> Dict[str, str]: - """Every parameter row of the source model as ``{path: unit}``.""" - parameters: Dict[str, str] = {} - self._collect_parameters(self.model.invisibleRootItem(), parameters) - return parameters - - def _collect_parameters( - self, parent: QtGui.QStandardItem, parameters: Dict[str, str] - ) -> None: - for row in range(parent.rowCount()): - item = parent.child(row, 0) - if item is None: - continue - if item.element is not None: # type: ignore[attr-defined] - # a parameter row; a submodule row's element is None - unitItem = parent.child(row, 1) - parameters[item.name] = "" if unitItem is None else unitItem.text() - if item.hasChildren(): - self._collect_parameters(item, parameters) - - def _apply_tints_to_rows( - self, parent: QtGui.QStandardItem, claims: Dict[str, Claim] - ) -> None: - """Tint every row of ``parent`` that has a Claim with the Claiming - Type's colour on all columns and store its Type stack on the gutter - item; clear the background of the rows without one.""" - for row in range(parent.rowCount()): - rowItems = [parent.child(row, col) for col in range(LOCK_COLUMN + 1)] - item = rowItems[0] - if item is None: - continue - gutterItem = rowItems[GUTTER_COLUMN] - if gutterItem is None: - gutterItem = QtGui.QStandardItem() - parent.setChild(row, GUTTER_COLUMN, gutterItem) - claim = claims.get(item.name) - colours = ( - self.typePalette.colours(claim.type) if claim is not None else None - ) - if claim is not None and colours is not None: - # claimed rows carry the Claiming Type's tint, alternating - # with tintAlt over the sibling rows so the striping survives - background = colours["tintAlt"] if item.row() % 2 else colours["tint"] - for rowItem in rowItems: - if rowItem is not None: - rowItem.setData( - background, QtCore.Qt.ItemDataRole.BackgroundRole - ) - gutterItem.setData(claim.stack[:3], GUTTER_ROLE) - else: - # a lost claim reverts the row to the default look - for rowItem in rowItems: - if rowItem is not None: - rowItem.setData(None, QtCore.Qt.ItemDataRole.BackgroundRole) - gutterItem.setData([], GUTTER_ROLE) - if item.hasChildren(): - self._apply_tints_to_rows(item, claims) - - # ------------------------------------------------------------------ - # the Types pane (plan task 5.5) - # ------------------------------------------------------------------ - - @QtCore.Slot() - def refresh_types_pane(self) -> None: - """Rebuild the Types pane's three panes from the client-side state - (plan task 5.5): the Types, the model's parameter rows and the - tint palette. - - Runs at the end of :meth:`apply_tints` — so a refresh, a profile - load, a ``pm-type-update`` Broadcast and a structural Broadcast - all refresh it — and after every pane action's Server call - returns (the Broadcast arrives on top of that; a double rebuild - is fine). The pane keeps the selected Type across rebuilds and - drops the selection when the Type is gone.""" - self.typesPane.rebuild( - self.state.types, self._model_parameters(), self.typePalette - ) - - @QtCore.Slot(str) - def _on_pane_type_selected(self, name: str) -> None: - """The Types pane's selected Type changed: re-render the entries - and Instances panes for it.""" - self.typesPane.refresh_selected_panes( - self.state.types, self._model_parameters(), self.typePalette - ) - - @QtCore.Slot(str) - def _on_pane_add_type(self, name: str) -> None: - """The New type strip: create the Type. A refused creation shows - the Server's error text on the strip's note; on success the new - Type is selected once the pane rebuilds (the ``pm-type-update`` - Broadcast brings it into the state).""" - try: - self.instrument.add_type(name) - except Exception as exc: - self.typesPane.show_type_error(str(exc)) - else: - self.typesPane.reset_type_note() - self.typesPane.newTypeEdit.clear() - self.typesPane.select_type(name) - self.refresh_types_pane() - - @QtCore.Slot(str, str, str, str) - def _on_pane_add_entry( - self, type_name: str, path: str, default_text: str, unit: str - ) -> None: - """The "Add to type" strip: add the entry with its parsed default - (``None`` when the text is empty) and unit (D11, D13). A refused - edit shows the Server's error text on the entries pane's note.""" - try: - self.instrument.add_type_parameter( - type_name, path, default=parse_default_text(default_text), unit=unit - ) - except Exception as exc: - self.typesPane.show_entries_error(str(exc)) - else: - self.typesPane.reset_entries_note() - self.typesPane.entryNameEdit.clear() - self.typesPane.entryDefaultEdit.clear() - self.typesPane.entryUnitEdit.clear() - self.refresh_types_pane() - - @QtCore.Slot(str, str) - def _on_pane_remove_entry(self, type_name: str, path: str) -> None: - """An own entry's Remove button: remove the entry from the Type - only (D13) — the Instances keep the parameter. A refused removal - shows the Server's error text on the entries pane's note.""" - try: - self.instrument.remove_type_parameter(type_name, path) - except Exception as exc: - self.typesPane.show_entries_error(str(exc)) - else: - self.typesPane.reset_entries_note() - self.refresh_types_pane() - - @QtCore.Slot(str, str, str) - def _on_pane_set_default(self, type_name: str, path: str, text: str) -> None: - """An own entry's committed default editor (Return or the set - button): set the entry's default to the parsed text (D13). A - refused set shows the Server's error text on the entries pane's - note.""" - try: - self.instrument.set_type_parameter_default( - type_name, path, parse_default_text(text) - ) - except Exception as exc: - self.typesPane.show_entries_error(str(exc)) - else: - self.typesPane.reset_entries_note() - self.refresh_types_pane() - - @QtCore.Slot(str, str) - def _on_pane_toggle_type_lock(self, type_name: str, path: str) -> None: - """An entry's Type Lock toggle: declare the Type Lock on the - default Globals Target while the entry has no Target, remove only - the rule while it has one (D17). A refused toggle shows the - Server's error text on the entries pane's note; the Instance - parameters a declaration skips (D17) are named on it.""" - blueprint = self.state.types.get(type_name) - target = None - if blueprint is not None: - target = blueprint.parameters.get(path, {}).get("target") - try: - if target is None: - skipped = self.instrument.lock_type_parameter(type_name, path) - else: - self.instrument.unlock_type_parameter(type_name, path) - skipped = [] - except Exception as exc: - self.typesPane.show_entries_error(str(exc)) - else: - if skipped: - self.typesPane.show_entries_note(f"skipped: {', '.join(skipped)}") - else: - self.typesPane.reset_entries_note() - self.refresh_types_pane() - - @QtCore.Slot(str, str, str) - def _on_pane_add_nested( - self, type_name: str, submodule: str, nested: str - ) -> None: - """The "Nested type" strip: require the Nested Type ``nested`` at - the submodule (D11, D13). A refused edit shows the Server's error - text on the entries pane's note.""" - try: - self.instrument.add_nested_type(type_name, submodule, nested) - except Exception as exc: - self.typesPane.show_entries_error(str(exc)) - else: - self.typesPane.reset_entries_note() - self.typesPane.nestedAtEdit.clear() - self.refresh_types_pane() - - @QtCore.Slot(str, str) - def _on_pane_remove_nested(self, type_name: str, submodule: str) -> None: - """An own Nested Type's Remove button: remove the requirement - (D13) — the Instances keep the parameters. A refused removal - shows the Server's error text on the entries pane's note.""" - try: - self.instrument.remove_nested_type(type_name, submodule) - except Exception as exc: - self.typesPane.show_entries_error(str(exc)) - else: - self.typesPane.reset_entries_note() - self.refresh_types_pane() - - @QtCore.Slot(str, str) - def _on_pane_add_instance(self, type_name: str, name: str) -> None: - """The New instance strip: create the Instance ``name`` of the - Type (D14). A refused creation shows the Server's error text on - the instances pane's note.""" - try: - self.instrument.add_instance(type_name, name) - except Exception as exc: - self.typesPane.show_instances_error(str(exc)) - else: - self.typesPane.reset_instances_note() - self.typesPane.newInstanceEdit.clear() - self.refresh_types_pane() - - @QtCore.Slot(str, str) - def _on_pane_show_instance(self, type_name: str, instance: str) -> None: - """An instance row's Show button: switch to the Parameters tab, - clear the filter, expand the tree and select the Instance's first - parameter row — the first effective entry under it, the submodule - row as the fallback — scrolled into view.""" - self.tabs.setCurrentIndex(0) - self.lineEdit.clear() - self.view.expandAll() - blueprint = self.state.types.get(type_name) - candidates = [instance] - if blueprint is not None and blueprint.effective: - first = sorted(blueprint.effective, key=lambda entry: entry.split("."))[0] - candidates.insert(0, f"{instance}.{first}") - for path in candidates: - matches = self.model.findItems( - path, - cast( - "QtCore.Qt.MatchFlags", - QtCore.Qt.MatchFlag.MatchExactly - | QtCore.Qt.MatchFlag.MatchRecursive, - ), - 0, - ) - if not matches: - continue - proxy_index = self.proxyModel.mapFromSource( - self.model.indexFromItem(matches[0]) - ) - if proxy_index.isValid(): - self.view.setCurrentIndex(proxy_index) - self.view.scrollTo(proxy_index) - break - - @QtCore.Slot() - def loadFromFile(self, loadFile: Optional[str] = None) -> None: - try: - self.instrument.fromFile(filePath=loadFile, deleteMissing=False) - self.refreshAll() - - except Exception as e: - logger.info(f"Loading failed. {type(e)}: {e.args}") - - @QtCore.Slot() - def saveToFile(self) -> None: - try: - self.instrument.toFile() - except Exception as e: - logger.info(f"Saving failed. {type(e)}: {e.args}") - - -# ----------------- Parameters Manager Classes - Ending -------------------------------- - # ----------------- Methods Display Classes - Beginning -------------------------------- diff --git a/src/instrumentserver/gui/parameter_manager/__init__.py b/src/instrumentserver/gui/parameter_manager/__init__.py new file mode 100644 index 0000000..5cba22e --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/__init__.py @@ -0,0 +1,66 @@ +"""The Parameter Manager GUI. + +- :mod:`.logic` holds what builds no widgets: the Type claims and tint + palette, the Lock rows and texts, the Types tab rows, and :class:`PMState`. +- :mod:`.panels` holds the gutter delegate, the Lock arm strip, the Locks + panel and the Types tab. +- :mod:`.widget` holds :class:`ParameterManagerGui` and the model, tree view + and create form it is built from. + +Station configs name the widget as +``instrumentserver.gui.parameter_manager.ParameterManagerGui``; the older +``instrumentserver.gui.instruments.ParameterManagerGui`` still works. +""" + +from .logic import ( # noqa: F401 + GUTTER_COLUMN, + GUTTER_ROLE, + GUTTER_WIDTH, + LOCK_COLOUR, + LOCK_COLUMN, + LOCK_COLUMN_WIDTH, + TINT_COLOURS, + TINT_PALETTE, + Claim, + EntryRow, + LockRow, + PMState, + TypePalette, + also_types, + build_lock_rows, + compute_claims, + followers_reaching, + instances_of_type, + lock_button_tooltip, + lock_column_text, + lock_root, + parse_default_text, + rank_lock_targets, + relative_path, + type_entry_rows, +) +from .panels import ( # noqa: F401 + ENTRIES_DEFAULT_WIDTH, + ENTRIES_LOCK_WIDTH, + ENTRIES_UNIT_WIDTH, + INSTANCES_ALSO_WIDTH, + INSTANCES_BUTTON_WIDTH, + INSTANCES_COUNT_WIDTH, + LOCK_PANEL_BUTTONS_WIDTH, + LOCK_PANEL_NOTE, + LOCK_PANEL_VALUE_WIDTH, + LOCK_ROW_ROLE, + GutterDelegate, + LockArmStrip, + LocksPanel, + TypesPane, + make_lock_button, +) +from .widget import ( # noqa: F401 + AddParameterWidget, + ModelParameterManager, + ParameterDeleteDelegate, + ParameterManagerGui, + ParameterManagerTreeView, + ProfilesManager, +) diff --git a/src/instrumentserver/gui/parameter_manager/logic.py b/src/instrumentserver/gui/parameter_manager/logic.py new file mode 100644 index 0000000..ea4101b --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/logic.py @@ -0,0 +1,805 @@ +"""Parameter Manager GUI logic that builds no widgets: the Type claims and +tint palette, the Lock rows and texts, the Types tab rows, and the +:class:`PMState` cache of Types and Locks.""" + +import ast +from dataclasses import dataclass +from typing import ( + Any, + Dict, + Iterable, + List, + Mapping, + Optional, + Tuple, + cast, +) + +from ... import QtCore, QtGui +from ...blueprints import ( + PMLockBluePrint, + PMTypeBluePrint, +) + +# ----------------- Tints -------------------------------------------------------------- + + +#: Logical index of the gutter column of :class:`.ModelParameterManager`, +#: whose items carry a row's stack of Types for the +#: :class:`.GutterDelegate` to draw. The existing columns keep their +#: indexes: name (0), unit (1), delegate (2). +GUTTER_COLUMN = 3 + +#: Fixed pixel width of the gutter column in the view. +GUTTER_WIDTH = 12 + +#: Data role under which a row's stack of Type names is stored on its +#: gutter item; :class:`.GutterDelegate` reads it to draw the bands. +GUTTER_ROLE = cast( + "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 1 +) + +#: The mock's TINTS, light values only (D21: no dark theme): ``tint`` and +#: ``tintAlt`` are the row background of a claimed row (``tintAlt`` for +#: every other sibling row), ``bar`` the colour of its gutter band. The +#: slot of a Type is its index in this list. +TINT_PALETTE: List[Dict[str, str]] = [ + {"tint": "#e8f1fb", "tintAlt": "#dfe9f6", "bar": "#4a7fc1"}, + {"tint": "#e9f4e9", "tintAlt": "#e0ede0", "bar": "#4f9e57"}, + {"tint": "#f6efe4", "tintAlt": "#efe7db", "bar": "#b98a3e"}, + {"tint": "#f9ecec", "tintAlt": "#f2e3e3", "bar": "#b5605f"}, + {"tint": "#e5f4f2", "tintAlt": "#dcece9", "bar": "#3f9490"}, +] + +#: The palette as QColors, in the same slot order. +TINT_COLOURS: List[Dict[str, QtGui.QColor]] = [ + {name: QtGui.QColor(value) for name, value in entry.items()} + for entry in TINT_PALETTE +] + + +@dataclass +class Claim: + """What the tree shows for one row that Types carry (the mock's + ``claims()``): the Claiming Type whose tint the row shows, the Instance + submodule path that claims it, and every Type carrying the row, + outermost first (the gutter draws one band per Type, up to three).""" + + type: str + instance: str + stack: List[str] + + +def _nested_claim_prefixes( + blueprint: PMTypeBluePrint, + types: Mapping[str, PMTypeBluePrint], +) -> Dict[str, str]: + """Map every effective path of the Type ``blueprint`` that a Nested + Type defines to the dotted submodule chain under which its defining + Type sits (the mock's ``at``): a ``qubit`` nesting a ``readout`` at its + submodule ``readout``, with the ``readout`` nesting a ``pulse_window`` + at ``pw``, maps the effective path ``readout.pw.win`` to + ``readout.pw``. + + Mirrors how ``params.py`` expands the effective set + (``_collect_effective``): the entries a Type defines itself are left + out (they claim at the Instance itself) and each Nested Type's own + entries are recorded under the chain that leads to it. + """ + at_by_path: Dict[str, str] = {} + + def walk(blueprint: PMTypeBluePrint, prefix: str, seen: Tuple[str, ...]) -> None: + for submodule, nested_name in blueprint.nested.items(): + if nested_name in seen: + continue # cycles are refused by the Parameter Manager + nested = types.get(nested_name) + if nested is None: + continue + at = prefix + submodule + for path, spec in nested.effective.items(): + if spec.get("from_type") == nested_name: + at_by_path[f"{at}.{path}"] = at + walk(nested, f"{at}.", seen + (nested_name,)) + + walk(blueprint, "", (blueprint.name,)) + return at_by_path + + +def _carries_effective_set( + instance: str, + effective: Mapping[str, Mapping[str, str]], + parameters: Mapping[str, str], +) -> bool: + """Whether the candidate Instance ``instance`` carries every path of + the effective set ``effective`` with the unit the Type declares (D12): + matching requires existence and unit, compared as strings; values are + irrelevant.""" + prefix = f"{instance}." + for path, spec in effective.items(): + if parameters.get(prefix + path) != spec["unit"]: + return False + return True + + +def _instance_candidates(parameters: Mapping[str, str]) -> List[str]: + """Every submodule path the parameter rows imply, sorted: every proper + dotted prefix of a parameter path, never the root and never anything + under the Globals submodule (D12). This is the candidate set both + :func:`compute_claims` and :func:`instances_of_type` match against.""" + candidates = set() + for path in parameters: + segments = path.split(".") + for depth in range(1, len(segments)): + candidate = ".".join(segments[:depth]) + if "_globals" in candidate.split("."): + continue # Globals is excluded from matching at any depth + candidates.add(candidate) + return sorted(candidates) + + +def compute_claims( + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> Dict[str, Claim]: + """The mock's ``claims()`` ported to the client-side state (plan task + 5.2): which Type claims each row of the Parameter Manager tree, and + which stack of Types carries it. + + :param types: the Parameter Manager's Types (``PMState.types``), each + as its :class:`PMTypeBluePrint`. + :param parameters: every parameter row of the tree as ``{path relative + to the Parameter Manager: unit}``. + :return: for every claimed parameter path and submodule path, its + :class:`Claim`. + + Matching mirrors ``ParameterManager.instances_of`` (D12) client-side: + a candidate is every submodule path derived from the parameter paths + (every proper dotted prefix; never the root, never anything under + Globals) and it is an Instance when it carries every effective path + with the declared unit. The Claiming Type is the innermost (the + longest Instance path), then the largest effective set, then the Type + name. A Nested Type claims at and below its submodule, so a row it + defines is claimed by it, with the outer Types behind it in the stack. + """ + # candidate Instances: every proper dotted prefix of a parameter path + candidates = _instance_candidates(parameters) + + claims_by_path: Dict[str, List[Tuple[str, str, int]]] = {} + winning: Dict[str, Tuple[str, str, int]] = {} + + def put(path: str, type_name: str, instance: str, size: int) -> None: + # one (Type, Instance, effective set size) claim, as the mock's + # all/map pair; the winner keeps the innermost Instance, then the + # largest effective set, then the Type name + claim = (type_name, instance, size) + claims_by_path.setdefault(path, []).append(claim) + old = winning.get(path) + if old is None or (-len(instance), -size, type_name) < ( + -len(old[1]), + -old[2], + old[0], + ): + winning[path] = claim + + for type_name, blueprint in types.items(): + effective = blueprint.effective + if not effective: + continue # an empty Type has no Instances + size = len(effective) + at_by_path = _nested_claim_prefixes(blueprint, types) + for instance in candidates: + if not _carries_effective_set(instance, effective, parameters): + continue + # the Instance row itself is claimed by its Type, as in the mock + put(instance, type_name, instance, size) + for path in effective: + # every row at and above the parameter, down to the + # parameter itself, is claimed at the Instance + at = at_by_path.get(path, "") + spec = effective[path] + owner_instance = f"{instance}.{at}" if at else None + at_depth = len(at.split(".")) if at else 0 + segments = path.split(".") + for depth in range(1, len(segments) + 1): + row = f"{instance}.{'.'.join(segments[:depth])}" + put(row, type_name, instance, size) + if owner_instance is not None and depth >= at_depth: + # the Nested Type claims at and below its submodule + put(row, spec["from_type"], owner_instance, size) + + claims: Dict[str, Claim] = {} + for path, path_claims in claims_by_path.items(): + # the stack is every Type carrying the row, outermost first + # (shortest Instance path, then the larger effective set), + # de-duplicated by Type + stack: List[str] = [] + for name in [ + entry[0] + for entry in sorted( + path_claims, key=lambda entry: (len(entry[1]), -entry[2], entry[0]) + ) + ]: + if name not in stack: + stack.append(name) + type_name, instance, _ = winning[path] + claims[path] = Claim(type=type_name, instance=instance, stack=stack) + return claims + + +class TypePalette: + """Assigns the fixed tint palette's slots to the Types the GUI knows. + + A Type keeps its slot while it exists: the slot is assigned when the + GUI first sees the Type (in ``PMState.types`` order after a refresh, + then each new Type from a ``pm-type-update`` Broadcast), it never + changes while the Type is in the state, and it is freed when the Type + is removed. A new Type takes the lowest free slot, or slot 0 when all + five are used (the mock's ``freeTint`` recycles when exhausted). + """ + + def __init__(self) -> None: + self.slots: Dict[str, int] = {} + + def sync(self, type_names: Any) -> None: + """Free the slots of Types that are gone and assign slots to new + ones, in the given creation order. + + :param type_names: the names of the Types the GUI knows + (``PMState.types``). + """ + names = list(type_names) + for name in [known for known in self.slots if known not in names]: + del self.slots[name] + used = set(self.slots.values()) + for name in names: + if name in self.slots: + continue + slot = next( + (index for index in range(len(TINT_PALETTE)) if index not in used), + 0, + ) + self.slots[name] = slot + used.add(slot) + + def colours(self, type_name: str) -> Optional[Dict[str, QtGui.QColor]]: + """The palette entry of the Type ``type_name`` (``tint``, + ``tintAlt`` and ``bar``), or ``None`` when it has no slot.""" + slot = self.slots.get(type_name) + return None if slot is None else TINT_COLOURS[slot] + + def bar_colour(self, type_name: str) -> Optional[QtGui.QColor]: + """The gutter band colour of the Type ``type_name``.""" + colours = self.colours(type_name) + return None if colours is None else colours["bar"] + + +# ----------------- Locks -------------------------------------------------------------- + + +#: Logical index of the Lock column of :class:`.ModelParameterManager` +#: (plan task 5.3). The existing columns keep their indexes: name (0), +#: unit (1), delegate (2), gutter (3). The view shows the Lock column +#: between the unit and the delegate column. +LOCK_COLUMN = 4 + +#: Fixed default pixel width of the Lock column in the view (the user can +#: resize it: the section is Interactive). +LOCK_COLUMN_WIDTH = 140 + +#: The mock's one purple (its ``--log-value`` token): the fill of a row's +#: lock button while its Lock is locked. +LOCK_COLOUR = "#7e5bef" + + +def lock_button_tooltip(locked: bool, target: str) -> str: + """The lock/relock button's tooltip for one Lock state (the mock's + strings), with ``target`` relative to the Parameter Manager. Shared by + the tree's per-row widget (plan task 5.3) and the Locks panel (plan + task 5.4).""" + if locked: + return f"locked to {target} — unlock and go back to its own value" + return f"unlocked — lock to {target} again" + + +def relative_path(full: str, instrument_name: str) -> str: + """The path relative to the Parameter Manager: ``full`` with the + ``.`` prefix stripped. ``PMLockBluePrint.target`` + stores the full dotted path, while model item names and every string + the GUI shows the user are relative to the Parameter Manager.""" + prefix = f"{instrument_name}." + return full[len(prefix):] if full.startswith(prefix) else full + + +def lock_column_text( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> str: + """The text the Lock column shows for the parameter row ``path`` (a + path relative to the Parameter Manager), computed client-side over the + state's Locks (plan task 5.3; the mock's lock cell). + + A Follower shows its own Lock state: ``locked to `` while + locked, ``unlocked · `` (middle dot) while unlocked, with the + Target relative to the Parameter Manager. A parameter that is no + Follower but the Target of ``N`` Locks — locked and unlocked alike, + the way :meth:`ParameterManager.followers_of` counts — shows + ``target ×N`` (multiplication sign). Every other row shows nothing. + + A row that is both Follower and Target shows its Follower text, which + wins over the Target note (the mock's ``rec.lockedTo || srcNote(p)``). + """ + lock = locks.get(path) + if lock is not None: + # the Follower's own Lock state wins over the Target note + target = relative_path(lock.target, instrument_name) + if lock.locked: + return f"locked to {target}" + return f"unlocked · {target}" + full_path = f"{instrument_name}.{path}" + count = sum(1 for other in locks.values() if other.target == full_path) + if count: + return f"target ×{count}" + return "" + + +def followers_reaching( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> List[str]: + """Paths (relative to the Parameter Manager) of every Follower whose + locked Lock targets the parameter at ``path``, directly or over a + chain of locked Locks. + + Only locked hops count (D7): an unlocked Lock answers ``get`` with its + own value, so the Followers behind it do not see an update made past + it. The walk follows each hop's Target and stops there — no infinite + loop on a cycle, and every Follower appears once. + """ + prefix = f"{instrument_name}." + found: List[str] = [] + seen: set = set() + targets = [prefix + path] + index = 0 + while index < len(targets): + current = targets[index] + index += 1 + for follower, lock in locks.items(): + if not lock.locked or lock.target != current or follower in seen: + continue + seen.add(follower) + found.append(follower) + targets.append(prefix + follower) + return found + + +def rank_lock_targets( + follower: str, + candidates: Iterable[str], + claims: Mapping[str, Claim], + arm_rel: Optional[str] = None, +) -> List[str]: + """The arm strip's Target candidates in the mock's completer order. + + ``arm_rel`` is the Follower's path relative to its Instance (the part + behind the Claiming Type's Instance path), or ``None`` when the + Follower is claimed by no Type; an explicit ``arm_rel`` argument + overrides it, which the Types tab's Type Lock re-target (plan task + 5.5) uses to rank for a Type's entry path — there is no claimed + Follower and so nothing to exclude. Rank 0: the candidate's own + relative path equals ``arm_rel`` (the same leaf on a sibling Instance, + the mock's first pick). Rank 1: ``.`` occurs in the candidate + (a submodule on the way). Rank 2: everything else. Equal ranks order + alphabetically; the Follower itself is never a candidate. Cycles are + not filtered here: the Server refuses them and the arm strip shows its + error text. + """ + if arm_rel is None: + follower_claim = claims.get(follower) + arm_rel = ( + follower[len(follower_claim.instance) + 1:] + if follower_claim is not None + else None + ) + + def own_rel(candidate: str) -> Optional[str]: + claim = claims.get(candidate) + if claim is None: + return None + return candidate[len(claim.instance) + 1:] + + ranked: List[Tuple[int, str]] = [] + for candidate in candidates: + if candidate == follower: + continue # the Follower itself is never a candidate + rel = own_rel(candidate) + if arm_rel is not None and rel == arm_rel: + rank = 0 + elif arm_rel is not None and f".{arm_rel}" in candidate: + rank = 1 + else: + rank = 2 + ranked.append((rank, candidate)) + ranked.sort(key=lambda entry: (entry[0], entry[1])) + return [path for _, path in ranked] + + +@dataclass +class LockRow: + """One row of the Locks panel (plan task 5.4): a Target of one or more + Locks — plain, or the Target of a Type Lock — and the Followers beneath + it, recursively for chains. ``type_locks`` holds every ``(Type name, + entry path)`` whose Type Lock Target the row is; ``lock`` is the row's + own Lock (``None`` for a plain Target).""" + + path: str + type_locks: List[Tuple[str, str]] + lock: Optional[PMLockBluePrint] + children: List["LockRow"] + + +def lock_root( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> str: + """The end of the chain of locked Locks that starts at ``path`` (the + mock's ``root``): the parameter a locked read at ``path`` finally asks. + Only locked hops count (D7): an unlocked Lock answers ``get`` with its + own value, so the walk stops there. A ``seen`` set guards against a + cycle. Paths are relative to the Parameter Manager, except the stored + ``PMLockBluePrint.target``, which is relativized on the way.""" + current = path + seen: set = set() + while current not in seen: + seen.add(current) + lock = locks.get(current) + if lock is None or not lock.locked: + return current + current = relative_path(lock.target, instrument_name) + return current + + +def build_lock_rows( + locks: Mapping[str, PMLockBluePrint], + types: Mapping[str, PMTypeBluePrint], + instrument_name: str, +) -> List[LockRow]: + """The Locks panel's rows from the client-side state (plan task 5.4; + the mock's locks-panel walk). + + ``locks`` maps each Follower's path (relative to the Parameter + Manager) to its :class:`PMLockBluePrint`; ``types`` maps each Type's + name to its :class:`PMTypeBluePrint`. An unlocked Lock still + remembers its Target (D5), so a Follower's Lock names its Target + whether the Lock is locked or not. + + The Targets are the unique Targets of the Locks, in ``locks`` order. + The roots are the Targets that carry no Lock of their own, the Type + Lock Targets first (a stable sort, like the mock's), each walked + recursively into its Followers — a ``seen`` set guards against loops — + and then any Target the first walk did not reach (the mock's second + pass, e.g. a cycle among Followers). Every row carries its own Lock + (``None`` for a plain Target) and its ``(Type, entry)`` pairs. + """ + + def target_of(follower: str) -> Optional[str]: + lock = locks.get(follower) + return ( + None if lock is None else relative_path(lock.target, instrument_name) + ) + + targets: List[str] = [] + for follower in locks: + target = target_of(follower) + if target is not None and target not in targets: + targets.append(target) + + def type_locks_at(path: str) -> List[Tuple[str, str]]: + found: List[Tuple[str, str]] = [] + for type_name, blueprint in types.items(): + for entry_path, spec in blueprint.parameters.items(): + entry_target = spec.get("target") + if ( + entry_target is not None + and relative_path(entry_target, instrument_name) == path + ): + found.append((type_name, entry_path)) + return found + + def followers(path: str) -> List[str]: + return [ + follower for follower in locks if target_of(follower) == path + ] + + rows: List[LockRow] = [] + seen: set = set() + + def walk(path: str) -> Optional[LockRow]: + if path in seen: + return None + seen.add(path) + row = LockRow( + path=path, + type_locks=type_locks_at(path), + lock=locks.get(path), + children=[], + ) + for child_path in followers(path): + child = walk(child_path) + if child is not None: + row.children.append(child) + return row + + # Type Lock Targets first, plain Targets follow — a stable sort, + # like the mock's + roots = [target for target in targets if target not in locks] + roots.sort(key=lambda target: 0 if type_locks_at(target) else 1) + for target in roots: + row = walk(target) + if row is not None: + rows.append(row) + # the mock's second pass: any Target the first walk did not reach + for target in targets: + row = walk(target) + if row is not None: + rows.append(row) + return rows + + +def _lock_row_paths(rows: List[LockRow]) -> List[str]: + """Every row path of the built rows, depth first.""" + paths: List[str] = [] + for row in rows: + paths.append(row.path) + paths.extend(_lock_row_paths(row.children)) + return paths + + +# ----------------- Types tab ---------------------------------------------------------- + + +@dataclass +class EntryRow: + """One row of the Types tab's entries pane (plan task 5.5; the mock's + ``tParamRows``): a submodule row of the selected Type's tree, or one + entry of it. + + ``kind`` is ``"submodule"`` or ``"entry"``. A submodule row carries + ``nested_type`` — the Type required at that submodule, or ``None`` for + a structural row that only carries the rows below it. An entry row + carries + the effective entry's ``unit`` and defining Type (``from_type``), + whether the selected Type defines the entry itself (``own``), its + ``default`` — an own entry's stored default, a Nested Type entry's + default as stored on the defining Type — and, own entries only, the + ``target`` of the entry's Type Lock relative to the Parameter Manager + (``None`` while it has none). + """ + + path: str + kind: str + unit: str = "" + nested_type: Optional[str] = None + own: bool = False + from_type: Optional[str] = None + default: Any = None + target: Optional[str] = None + + +def _nested_type_at( + blueprint: PMTypeBluePrint, + types: Mapping[str, PMTypeBluePrint], + submodule: str, +) -> Optional[str]: + """The Type required at the submodule ``submodule`` (a dotted path + relative to the Type ``blueprint``): the walk follows the ``nested`` + maps down the segments, the way :func:`_nested_claim_prefixes` walks. + ``None`` when no Nested Type is required there — a structural row — + or when a nested Type of the chain is missing from ``types``.""" + current = blueprint + for segment in submodule.split("."): + if current is None: + return None + nested_name = current.nested.get(segment) + if nested_name is None: + return None + current = types.get(nested_name) + return current.name if current is not None else None + + +def type_entry_rows( + type_name: str, + types: Mapping[str, PMTypeBluePrint], + instrument_name: str = "", +) -> List[EntryRow]: + """The entries-pane rows of the Type ``type_name`` (plan task 5.5): + its effective parameter set as a segment-sorted tree of submodule and + entry rows. + + The sort is segment-wise like the mock's (paths order by their dotted + segments), so a submodule row sorts directly before the rows below it + and the list reads as a tree in order. + + An entry the Type defines itself (``from_type == type_name``) is + ``own``: its ``default`` and ``target`` come from the Type's own + entry, with the stored Type Lock Target relativized with + ``instrument_name``. An entry a Nested Type defines shows that Type + as ``from_type`` and the defining Type's own default for the path + relative to it (the mock's ``ownerRel``). + + :param type_name: the selected Type's name. + :param types: the Parameter Manager's Types (``PMState.types``). + :param instrument_name: the Parameter Manager's name, for + relativizing the stored Type Lock Targets; without it the stored + full-form Targets are returned unchanged. + :return: the rows, parents before children. + """ + blueprint = types.get(type_name) + if blueprint is None: + return [] + at_by_path = _nested_claim_prefixes(blueprint, types) + rows: List[EntryRow] = [] + submodule_paths: set = set() + for path in sorted(blueprint.effective, key=lambda entry: entry.split(".")): + segments = path.split(".") + for depth in range(1, len(segments)): + submodule = ".".join(segments[:depth]) + if submodule in submodule_paths: + continue + submodule_paths.add(submodule) + rows.append( + EntryRow( + path=submodule, + kind="submodule", + nested_type=_nested_type_at(blueprint, types, submodule), + ) + ) + spec = blueprint.effective[path] + from_type = spec["from_type"] + own = from_type == type_name + if own: + entry = blueprint.parameters.get(path, {}) + default = entry.get("default") + target = entry.get("target") + if target is not None and instrument_name: + target = relative_path(target, instrument_name) + else: + at = at_by_path.get(path, "") + relative = path[len(at) + 1:] if at else path + defining = types.get(from_type) + default = ( + defining.parameters.get(relative, {}).get("default") + if defining is not None + else None + ) + target = None + rows.append( + EntryRow( + path=path, + kind="entry", + unit=spec["unit"], + own=own, + from_type=from_type, + default=default, + target=target, + ) + ) + return rows + + +def instances_of_type( + type_name: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> List[str]: + """Paths (relative to the Parameter Manager) of every Instance of the + Type ``type_name``, computed client-side over the model's parameter + rows (plan task 5.5; the mock's ``instancesOf``): the same candidate + rules :func:`compute_claims` matches by — every proper dotted prefix, + never the root, never anything under Globals — carrying every + effective path with the declared unit (D12). An empty Type has no + Instances. The Instances are sorted, for a stable pane order.""" + blueprint = types.get(type_name) + if blueprint is None: + return [] + effective = blueprint.effective + if not effective: + return [] + return [ + candidate + for candidate in _instance_candidates(parameters) + if _carries_effective_set(candidate, effective, parameters) + ] + + +def also_types( + instance: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> List[str]: + """Every Type the submodule ``instance`` is an Instance of (plan task + 5.5; the mock's ``also`` cell), in ``types`` order. The Types pane + shows the ones besides the selected Type as ``also , ``.""" + return [ + type_name + for type_name in types + if instance in instances_of_type(type_name, types, parameters) + ] + + +def parse_default_text(text: str) -> Any: + """The value a default line edit's text stands for: ``None`` when the + text is empty, otherwise the text parsed with ``ast.literal_eval``, + falling back to the raw string when it does not parse.""" + if text.strip() == "": + return None + try: + return ast.literal_eval(text) + except (ValueError, SyntaxError): + return text + + +# ----------------- Client-side state -------------------------------------------------- + + +class PMState: + """Client-side cache of a Parameter Manager's Types and Locks. + + The Parameter Manager GUI owns one instance (``ParameterManagerGui.state``) + so its widgets can react to Types and Locks without querying the Server + again. It starts empty and is filled from the Parameter Manager — a Proxy + Instrument or a local one — with :meth:`refresh`; the ``pm-lock-update`` + and ``pm-type-update`` Broadcasts then keep single entries current through + :meth:`apply_lock` and :meth:`apply_type` (D22). + + ``types`` maps each Type's name to its :class:`PMTypeBluePrint`; ``locks`` + maps each Follower's path relative to the Parameter Manager — the form + ``list_locks()`` returns — to its :class:`PMLockBluePrint`. + """ + + def __init__(self) -> None: + self.types: Dict[str, PMTypeBluePrint] = {} + self.locks: Dict[str, PMLockBluePrint] = {} + + def refresh(self, instrument: Any) -> None: + """Re-read every Type and Lock from the Parameter Manager. + + Works with a Proxy Instrument and with a local Parameter Manager: + both expose ``list_types``, ``get_type`` and ``list_locks``. + + :param instrument: the Parameter Manager whose Types and Locks to + read. + """ + self.types = { + type_name: instrument.get_type(type_name) + for type_name in instrument.list_types() + } + self.locks = dict(instrument.list_locks()) + + def apply_lock(self, path: str, lock: Optional[PMLockBluePrint]) -> None: + """Record the change a ``pm-lock-update`` Broadcast reports about + the Follower at ``path``. + + :param path: the Follower's path relative to the Parameter Manager. + :param lock: the Follower's :class:`PMLockBluePrint`, or ``None`` + when its Lock was removed (the entry is dropped then). + """ + if lock is None: + self.locks.pop(path, None) + else: + self.locks[path] = lock + + def apply_type(self, name: str, type_blueprint: Optional[PMTypeBluePrint]) -> None: + """Record the change a ``pm-type-update`` Broadcast reports about + the Type ``name``. + + :param name: the Type's name. + :param type_blueprint: the Type's :class:`PMTypeBluePrint`, or + ``None`` when the Type was removed (the entry is dropped then). + """ + if type_blueprint is None: + self.types.pop(name, None) + else: + self.types[name] = type_blueprint diff --git a/src/instrumentserver/gui/parameter_manager/panels.py b/src/instrumentserver/gui/parameter_manager/panels.py new file mode 100644 index 0000000..eeb6a0a --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/panels.py @@ -0,0 +1,1414 @@ +"""The Parameter Manager GUI's panels and delegates: the gutter bands, the +Lock arm strip and Locks panel, and the Types tab.""" + +import logging +from typing import ( + Any, + Dict, + Iterable, + List, + Mapping, + Optional, + cast, +) + +from ... import QtCore, QtGui, QtWidgets +from ...blueprints import ( + PMLockBluePrint, + PMTypeBluePrint, +) +from .. import keepSmallHorizontally +from ..parameters import ParameterWidget +from .logic import ( + GUTTER_ROLE, + GUTTER_WIDTH, + LOCK_COLOUR, + EntryRow, + LockRow, + TypePalette, + also_types, + instances_of_type, + lock_button_tooltip, + lock_root, + relative_path, + type_entry_rows, +) + +logger = logging.getLogger(__name__) + + +# ----------------- Tints -------------------------------------------------------------- + + +class GutterDelegate(QtWidgets.QStyledItemDelegate): + """Draws the gutter bands of a row's stack of Types into the gutter + column: up to three vertical bands of equal width filling the cell, + one per Type of the stack, outermost first, left to right, in the + Types' ``bar`` colours. A row with no stack paints nothing beyond the + background.""" + + def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: + super().__init__(parent) + # Owned by the Parameter Manager GUI and assigned after the view is + # built; the delegate only reads the Types' colours from it. + self.typePalette: Optional[TypePalette] = None + + def paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.text = "" + # the background first (alternating row or Type tint), then the bands + widget = opt.widget + style = ( + widget.style() if widget is not None else QtWidgets.QApplication.style() + ) + style.drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget + ) + if self.typePalette is None: + return + stack = index.data(GUTTER_ROLE) + if not stack: + return + bandWidth = opt.rect.width() / len(stack) + for band, type_name in enumerate(stack): + colour = self.typePalette.bar_colour(type_name) + if colour is None: + continue + painter.fillRect( + QtCore.QRectF( + opt.rect.x() + band * bandWidth, + opt.rect.y(), + bandWidth, + opt.rect.height(), + ), + colour, + ) + + def sizeHint( + self, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> QtCore.QSize: + return QtCore.QSize( + GUTTER_WIDTH, super().sizeHint(option, index).height() + ) + + +# ----------------- Locks -------------------------------------------------------------- + + +def make_lock_button( + parent: QtWidgets.QWidget, locked: bool, target: Optional[str] = None +) -> QtWidgets.QPushButton: + """The lock/relock toggle button shared by the tree's per-row widget + (plan task 5.3) and the Locks panel (plan task 5.4): the lock icon and + the purple ``locked`` fill. ``target`` is the Target relative to the + Parameter Manager for the state tooltip; the tree's delegate passes + ``None`` and leaves the tooltip to + :meth:`.ParameterManagerGui._update_row_lock_widget`.""" + button = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/lock.svg"), "", parent=parent + ) + button.setProperty("locked", locked) + button.setStyleSheet( + f"QPushButton[locked=\"true\"] {{ background-color: {LOCK_COLOUR} }}" + ) + if target is not None: + button.setToolTip(lock_button_tooltip(locked, target)) + keepSmallHorizontally(button) + return button + + +class LockArmStrip(QtWidgets.QWidget): + """The arm strip under the toolbar while a Lock's Target is being + picked (plan task 5.3): a label naming the Follower, a line edit with + a completer over the ranked candidate paths, a Cancel button and an + error label for the Server's refusal text. + + Picking works three ways: a completion from the popup, Return with the + exact typed path (or the first completion the completer filters for + the typed text; a text that matches no candidate picks nothing), and + clicking a tree row — the last one is wired by the Parameter Manager + GUI, which owns the strip. Cancel is the button or Escape while the + strip or one of its children has focus.""" + + #: Signal(str) + #: Emitted when a Target was picked. The path is relative to the + #: Parameter Manager. + targetPicked = QtCore.Signal(str) + + #: Signal() + #: Emitted when the user cancels the pick (Cancel button or Escape). + cancelled = QtCore.Signal() + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.label = QtWidgets.QLabel(self) + + self.lineEdit = QtWidgets.QLineEdit(self) + self.lineEdit.setPlaceholderText("type part of the target path, or click a row") + + # the completer keeps the ranked candidate order (UnsortedModel) + # and filters it by what the user typed + self.completerModel = QtCore.QStringListModel(self) + self.completer = QtWidgets.QCompleter(self) + self.completer.setModel(self.completerModel) + self.completer.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) + self.completer.setCaseSensitivity( + QtCore.Qt.CaseSensitivity.CaseInsensitive + ) + self.completer.setModelSorting( + QtWidgets.QCompleter.ModelSorting.UnsortedModel + ) + self.lineEdit.setCompleter(self.completer) + + self.cancelButton = QtWidgets.QPushButton("Cancel", self) + + self.errorLabel = QtWidgets.QLabel(self) + self.errorLabel.setStyleSheet( + "QLabel { background-color: red; color: white; font-weight: bold }" + ) + self.errorLabel.setVisible(False) + + layout.addWidget(self.label) + layout.addWidget(self.lineEdit, 1) + layout.addWidget(self.cancelButton) + layout.addWidget(self.errorLabel) + self.setLayout(layout) + + self.completer.activated[str].connect(self.targetPicked) # type: ignore[index] + self.lineEdit.returnPressed.connect(self._on_return_pressed) + self.cancelButton.clicked.connect(self.cancelled) + + self.escShortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Escape"), self) + self.escShortcut.setContext( + QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut + ) + self.escShortcut.activated.connect(self.cancelled) + + @QtCore.Slot() + def _on_return_pressed(self) -> None: + """Pick the exact typed path, or the first completion the + completer filters for the typed text (the mock's Enter picks the + first match); a text that matches no candidate picks nothing.""" + text = self.lineEdit.text().strip() + if not text: + return + if text in self.completerModel.stringList(): + self.targetPicked.emit(text) + return + # the completer's filtered matches for what was typed, in ranked + # order; its filter mode (MatchContains) and case sensitivity apply + self.completer.setCompletionPrefix(text) + if self.completer.completionCount() > 0: + first = self.completer.completionModel().index(0, 0) + self.targetPicked.emit( + self.completer.completionModel().data( + first, QtCore.Qt.ItemDataRole.DisplayRole + ) + ) + + def arm(self, follower: str, candidates: List[str]) -> None: + """Arm the strip for the Follower at ``follower``: name it in the + label, load the ranked candidates into the completer, clear the + line edit and any error, show the strip and focus the line edit.""" + self.label.setText(f"Target for {follower}") + self.completerModel.setStringList(candidates) + self.lineEdit.clear() + self.clear_error() + self.setVisible(True) + self.lineEdit.setFocus() + + def show_error(self, text: str) -> None: + """Show the Server's error text on the error label.""" + self.errorLabel.setText(text) + self.errorLabel.setVisible(True) + + def clear_error(self) -> None: + """Hide and clear the error label (the next pick or cancel does + this).""" + self.errorLabel.setText("") + self.errorLabel.setVisible(False) + + def disarm(self) -> None: + """Hide the strip and clear it.""" + self.setVisible(False) + self.lineEdit.clear() + self.clear_error() + + +#: The Locks panel's default note (the mock's ``lockNote``, in glossary +#: words): shown until an action error or a skipped-Lock warning replaces +#: it. +LOCK_PANEL_NOTE = ( + "A Type Lock row — marked with its Type — holds one value for every " + "Instance of that Type. Unlock a Follower to let it keep its own " + "value, remove its Lock to take it out; the lock button on the Type " + "Lock row locks them all again." +) + +#: Fixed pixel width of the Locks panel's value column (the mock's value +#: column) and of its buttons column. +LOCK_PANEL_VALUE_WIDTH = 200 +LOCK_PANEL_BUTTONS_WIDTH = 84 + +#: Data role under which a Locks panel row's path (relative to the +#: Parameter Manager) is stored on its first item, so the rows can be +#: found again after a rebuild. +LOCK_ROW_ROLE = cast( + "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 2 +) + + +class LocksPanel(QtWidgets.QWidget): + """The Locks panel right of the Parameter Manager tree (plan task 5.4; + the mock's locks panel): one root row per Target — the Type Lock + Targets first, labelled with their Type — with each Target's Followers + beneath it, recursively for chains. + + A Target row holds a value editor (a plain ``set`` on the Target); a + locked Follower row shows its value read-only, and every Follower row + that is not a Type Lock row carries the lock/relock toggle and the + remove button. A Type Lock row carries "lock all" and "remove rule". + The panel never talks to the Server itself: every action is emitted as + a signal — + ``toggleLockRequested``, ``removeLockRequested``, ``lockAllRequested``, + ``removeRuleRequested`` and ``lockSelectionRequested`` — and the + Parameter Manager GUI, which owns the panel, performs it and reports + errors and skipped Locks on the note label.""" + + #: Signal(str) + #: Emitted when the user presses a Follower row's lock/relock button; + #: the path is relative to the Parameter Manager. + toggleLockRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the user presses a Follower row's remove button; the + #: path is relative to the Parameter Manager. + removeLockRequested = QtCore.Signal(str) + + #: Signal(str, str, str) + #: Emitted when the user presses a Type Lock row's "lock all" button: + #: the Type's name, the entry path, and the entry's stored Target + #: relative to the Parameter Manager. + lockAllRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses a Type Lock row's "remove rule" + #: button: the Type's name and the entry path. + removeRuleRequested = QtCore.Signal(str, str) + + #: Signal() + #: Emitted when the user presses "Lock selection to…". + lockSelectionRequested = QtCore.Signal() + + def __init__( + self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None + ) -> None: + super().__init__(parent) + self.instrument_name = instrument_name + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.model = QtGui.QStandardItemModel(0, 3, self) + self.model.setHorizontalHeaderLabels(["locks", "value", ""]) + + self.view = QtWidgets.QTreeView(self) + self.view.setModel(self.model) + self.view.setHeaderHidden(False) + self.view.setAlternatingRowColors(True) + self.view.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + header = self.view.header() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.resizeSection(1, LOCK_PANEL_VALUE_WIDTH) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.resizeSection(2, LOCK_PANEL_BUTTONS_WIDTH) + + self.lockSelectionButton = QtWidgets.QPushButton( + "Lock selection to…", self + ) + self.selectedLabel = QtWidgets.QLabel(self) + self.selectedLabel.setText("no parameter selected") + + self.noteLabel = QtWidgets.QLabel(self) + self.noteLabel.setWordWrap(True) + self.noteLabel.setText(LOCK_PANEL_NOTE) + + layout.addWidget(self.view, 1) + selectionRow = QtWidgets.QHBoxLayout() + selectionRow.setContentsMargins(0, 0, 0, 0) + selectionRow.addWidget(self.lockSelectionButton) + selectionRow.addWidget(self.selectedLabel, 1) + layout.addLayout(selectionRow) + layout.addWidget(self.noteLabel) + self.setLayout(layout) + + # The widgets of every panel row, keyed by the row path (relative + # to the Parameter Manager); :meth:`refresh_values` re-reads the + # values without a rebuild. + self.rowWidgets: Dict[str, Dict[str, Any]] = {} + + self.lockSelectionButton.clicked.connect(self.lockSelectionRequested) + + def rebuild( + self, + rows: List[LockRow], + elements: Mapping[str, Any], + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + ) -> None: + """Rebuild every row from ``rows`` (see :func:`.build_lock_rows`). + + ``elements`` maps each row path to the row's parameter object (the + GUI resolves it through the Proxy or the local instrument); + ``types`` and ``locks`` are the client-side state the "lock all" + Target and the tooltips come from. Every row is expanded after the + rebuild; no collapsed state is kept.""" + self.model.removeRows(0, self.model.rowCount()) + self.rowWidgets = {} + self._build_rows(rows, elements, types, locks, self.model.invisibleRootItem()) + self.view.expandAll() + + def refresh_values(self, paths: Iterable[str]) -> None: + """Re-read the value of every named row the panel holds: + editors through :meth:`ParameterWidget.setWidgetFromParameter`, + the read-only labels of locked rows through a fresh ``get``. A row + whose widget is gone — a rebuild replaced it — is skipped.""" + for path in paths: + entry = self.rowWidgets.get(path) + if entry is None: + continue + try: + if entry.get("editor") is not None: + entry["editor"].setWidgetFromParameter() + elif ( + entry.get("label") is not None + and entry.get("element") is not None + ): + entry["label"].setText(str(entry["element"].get())) + except RuntimeError: + logger.debug( + f"Could not refresh the value of {path}. " + "Object is not being shown right now." + ) + + def show_error(self, text: str) -> None: + """Show an action error (the mock's ``lockError``) in red on the + note label.""" + self.noteLabel.setStyleSheet("QLabel { color: red }") + self.noteLabel.setText(text) + + def show_note(self, text: str) -> None: + """Show ``text`` on the note label in the normal colour (a + skipped-Lock warning, for example).""" + self.noteLabel.setStyleSheet("") + self.noteLabel.setText(text) + + def reset_note(self) -> None: + """Restore the default explanatory note.""" + self.show_note(LOCK_PANEL_NOTE) + + def _build_rows( + self, + rows: List[LockRow], + elements: Mapping[str, Any], + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + parent_item: QtGui.QStandardItem, + ) -> None: + for row in rows: + if row.type_locks: + label = ( + f"[type: {', '.join(t for t, _ in row.type_locks)}] {row.path}" + ) + else: + label = row.path + name_item = QtGui.QStandardItem(label) + name_item.setData(row.path, LOCK_ROW_ROLE) + value_item = QtGui.QStandardItem() + buttons_item = QtGui.QStandardItem() + parent_item.appendRow([name_item, value_item, buttons_item]) + self._build_row_widgets( + row, + elements.get(row.path), + types, + locks, + value_item, + buttons_item, + ) + self._build_rows( + row.children, elements, types, locks, name_item + ) + + def _build_row_widgets( + self, + row: LockRow, + element: Any, + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + value_item: QtGui.QStandardItem, + buttons_item: QtGui.QStandardItem, + ) -> None: + """Build one row's value cell (a read-only label for a locked + Follower, a value editor for every other row with a parameter) and + its buttons cell (the Type Lock controls, or the Follower's toggle + and remove), and record the widgets in ``rowWidgets``.""" + path = row.path + entry: Dict[str, Any] = { + "element": element, + "editor": None, + "label": None, + "toggle": None, + "remove": None, + "lockAll": None, + "removeRule": None, + } + self.rowWidgets[path] = entry + + if row.lock is not None and row.lock.locked: + # a locked Follower reads its Target's value (D3) and refuses + # writes: a read-only label, like the mock's + target = relative_path(row.lock.target, self.instrument_name) + root = lock_root(path, locks, self.instrument_name) + label = QtWidgets.QLabel(self.view.viewport()) + value = "" + if element is not None: + try: + value = element.get() + except Exception as exc: + logger.debug(f"could not read the value of {path}: {exc}") + label.setText(str(value)) + label.setToolTip(f"locked to {target} — set the value on {root}") + self.view.setIndexWidget(self.model.indexFromItem(value_item), label) + entry["label"] = label + elif element is not None: + editor = ParameterWidget(element, self.view.viewport()) + if row.type_locks: + tooltip = ( + f"set the value — every Instance of " + f"{row.type_locks[0][0]} follows it" + ) + else: + tooltip = "set the Target value — every locked Follower follows it" + editor.setButton.setToolTip(tooltip) + self.view.setIndexWidget(self.model.indexFromItem(value_item), editor) + entry["editor"] = editor + + container: Optional[QtWidgets.QWidget] = None + if row.type_locks: + # the Type Lock controls; like the mock, the first (Type, + # entry) pair acts when several share the Target + type_name, entry_path = row.type_locks[0] + blueprint = types.get(type_name) + spec = ( + blueprint.parameters.get(entry_path, {}) + if blueprint is not None + else {} + ) + stored = spec.get("target") + stored_relative = ( + relative_path(stored, self.instrument_name) + if stored is not None + else path + ) + container = QtWidgets.QWidget(self.view.viewport()) + buttons_layout = QtWidgets.QHBoxLayout(container) + buttons_layout.setContentsMargins(0, 0, 0, 0) + lock_all = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/lock.svg"), "", parent=container + ) + lock_all.setToolTip(f"lock every Instance of {type_name} to this again") + keepSmallHorizontally(lock_all) + lock_all.pressed.connect( + lambda: self.lockAllRequested.emit( + type_name, entry_path, stored_relative + ) + ) + remove_rule = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=container + ) + remove_rule.setStyleSheet("QPushButton { background-color: salmon }") + remove_rule.setToolTip( + "remove the Type Lock — the Instances' Locks stay until " + "removed one by one" + ) + keepSmallHorizontally(remove_rule) + remove_rule.pressed.connect( + lambda: self.removeRuleRequested.emit(type_name, entry_path) + ) + buttons_layout.addWidget(lock_all) + buttons_layout.addWidget(remove_rule) + entry["lockAll"] = lock_all + entry["removeRule"] = remove_rule + elif row.lock is not None: + # a Follower's lock/relock toggle and remove button + container = QtWidgets.QWidget(self.view.viewport()) + buttons_layout = QtWidgets.QHBoxLayout(container) + buttons_layout.setContentsMargins(0, 0, 0, 0) + target = relative_path(row.lock.target, self.instrument_name) + toggle = make_lock_button(container, row.lock.locked, target) + toggle.pressed.connect( + lambda follower=path: self.toggleLockRequested.emit(follower) + ) + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=container + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip(f"remove the Lock — {path} keeps its own value") + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda follower=path: self.removeLockRequested.emit(follower) + ) + buttons_layout.addWidget(toggle) + buttons_layout.addWidget(remove) + entry["toggle"] = toggle + entry["remove"] = remove + if container is not None: + self.view.setIndexWidget( + self.model.indexFromItem(buttons_item), container + ) + + +# ----------------- Types tab ---------------------------------------------------------- + + +#: Fixed pixel widths of the entries pane's unit, "locked to" and default +#: columns (the mock's 60/200/252 trio). +ENTRIES_UNIT_WIDTH = 60 +ENTRIES_LOCK_WIDTH = 210 +ENTRIES_DEFAULT_WIDTH = 250 + +#: Fixed pixel widths of the instances pane's parameter-count, "also" and +#: button columns (the mock's 150/160/110 trio). +INSTANCES_COUNT_WIDTH = 110 +INSTANCES_ALSO_WIDTH = 160 +INSTANCES_BUTTON_WIDTH = 80 + + +class TypesPane(QtWidgets.QWidget): + """The Types tab (plan task 5.5; the mock's Types view): three panes + around the selected Type. + + Left: the list of Types — name, number of Instances and number of + effective parameters, each row tinted with the Type's colour — and the + New type strip. Right, above: the entries of the selected Type as a + tree. Own entries carry an editable default (Return or the set button + commits), a Remove button and the Type Lock toggle in the "locked to" + column, with a re-target button and the Target's path while locked. + Entries from Nested Types render read-only with "defined by ". + Submodule rows show ``type: `` in the "locked to" column and, for + the selected Type's own Nested Types, a Remove button. Beneath the + tree run the "Add to type" and "Nested type" strips and a note line. + Right, below: the Instances of the selected Type — name, parameter + count, the other Types the Instance also carries and a Show button — + with the New instance strip and a note line. + + The pane never talks to the Server: every action is emitted as a + signal — ``addTypeRequested``, ``addEntryRequested``, + ``removeEntryRequested``, ``setDefaultRequested``, + ``toggleTypeLockRequested``, ``retargetTypeLockRequested``, + ``addNestedRequested``, ``removeNestedRequested``, + ``addInstanceRequested`` and ``showInstanceRequested`` — and + :class:`.ParameterManagerGui`, which owns the pane, performs it and + reports errors and skipped Locks on the pane's note labels. + """ + + #: Signal(str) + #: Emitted when the user presses the New type strip's Add button; + #: the name is trimmed and not empty. + addTypeRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the selected Type changes (a row click, or a rebuild + #: that had to pick one). + typeSelected = QtCore.Signal(str) + + #: Signal(str, str, str, str) + #: Emitted when the user presses "Add to type": the Type's name, the + #: entry path, the default text and the unit. + addEntryRequested = QtCore.Signal(str, str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses an own entry's Remove button: the + #: Type's name and the entry path. + removeEntryRequested = QtCore.Signal(str, str) + + #: Signal(str, str, str) + #: Emitted when the user commits an own entry's default editor + #: (Return or the set button): the Type's name, the entry path and + #: the editor's text. + setDefaultRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses a Type Lock toggle: the Type's name + #: and the entry path. The GUI locks or unlocks from the entry's + #: stored Target. + toggleTypeLockRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses a locked entry's re-target button: + #: the Type's name and the entry path. + retargetTypeLockRequested = QtCore.Signal(str, str) + + #: Signal(str, str, str) + #: Emitted when the user presses "Add nested type": the Type's name, + #: the submodule and the Nested Type's name. + addNestedRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses an own Nested Type's Remove button: + #: the Type's name and the submodule. + removeNestedRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses the New instance strip's button: the + #: Type's name and the Instance's name. + addInstanceRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses an instance row's Show button: the + #: Type's name and the Instance's name. + showInstanceRequested = QtCore.Signal(str, str) + + def __init__( + self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None + ) -> None: + super().__init__(parent) + self.instrument_name = instrument_name + + # the selected Type, and one requested while the Server call that + # creates it is still in flight (honoured on the next rebuild) + self.selectedType: Optional[str] = None + self.requestedType: Optional[str] = None + # guards the selection slot against the rebuild's own index changes + self._building = False + + # the widgets of the entries rows, keyed by row path; and the Show + # buttons of the instance rows, keyed by instance path + self.entryWidgets: Dict[str, Dict[str, Any]] = {} + self.showButtons: Dict[str, QtWidgets.QPushButton] = {} + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal, self) + + # -- left: the list of Types and the New type strip + typeListPane = QtWidgets.QWidget(self.splitter) + typeListLayout = QtWidgets.QVBoxLayout(typeListPane) + typeListLayout.setContentsMargins(0, 0, 0, 0) + + self.typeModel = QtGui.QStandardItemModel(0, 3, self) + self.typeModel.setHorizontalHeaderLabels(["type", "instances", "parameters"]) + self.typeList = QtWidgets.QTreeView(typeListPane) + self.typeList.setModel(self.typeModel) + self.typeList.setRootIsDecorated(False) + self.typeList.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.typeList.setAlternatingRowColors(True) + typeHeader = self.typeList.header() + typeHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + for column, width in ((1, 70), (2, 90)): + typeHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + typeHeader.resizeSection(column, width) + + typeStrip = QtWidgets.QHBoxLayout() + typeStrip.setContentsMargins(0, 0, 0, 0) + typeStrip.addWidget(QtWidgets.QLabel("New type:")) + self.newTypeEdit = QtWidgets.QLineEdit(typeListPane) + self.newTypeEdit.setPlaceholderText("cavity") + self.addTypeButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), " Add" + ) + keepSmallHorizontally(self.addTypeButton) + typeStrip.addWidget(self.newTypeEdit, 1) + typeStrip.addWidget(self.addTypeButton) + self.typeNote = QtWidgets.QLabel(typeListPane) + + typeListLayout.addWidget(self.typeList, 1) + typeListLayout.addLayout(typeStrip) + typeListLayout.addWidget(self.typeNote) + + # -- right: the entries pane above the instances pane + rightPane = QtWidgets.QSplitter( + QtCore.Qt.Orientation.Vertical, self.splitter + ) + + entriesPane = QtWidgets.QWidget(rightPane) + entriesLayout = QtWidgets.QVBoxLayout(entriesPane) + entriesLayout.setContentsMargins(0, 0, 0, 0) + + self.entriesLabel = QtWidgets.QLabel(entriesPane) + self.entriesModel = QtGui.QStandardItemModel(0, 4, self) + self.entriesModel.setHorizontalHeaderLabels( + ["parameter", "unit", "locked to", "default"] + ) + self.entriesView = QtWidgets.QTreeView(entriesPane) + self.entriesView.setModel(self.entriesModel) + self.entriesView.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.entriesView.setAlternatingRowColors(True) + entriesHeader = self.entriesView.header() + entriesHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + for column, width in ( + (1, ENTRIES_UNIT_WIDTH), + (2, ENTRIES_LOCK_WIDTH), + (3, ENTRIES_DEFAULT_WIDTH), + ): + entriesHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + entriesHeader.resizeSection(column, width) + + entryStrip = QtWidgets.QHBoxLayout() + entryStrip.setContentsMargins(0, 0, 0, 0) + entryStrip.addWidget(QtWidgets.QLabel("Name:")) + self.entryNameEdit = QtWidgets.QLineEdit(entriesPane) + self.entryNameEdit.setPlaceholderText("pulses.pi.drag_multiplier") + entryStrip.addWidget(self.entryNameEdit, 2) + entryStrip.addWidget(QtWidgets.QLabel("Default:")) + self.entryDefaultEdit = QtWidgets.QLineEdit(entriesPane) + entryStrip.addWidget(self.entryDefaultEdit, 1) + entryStrip.addWidget(QtWidgets.QLabel("Unit:")) + self.entryUnitEdit = QtWidgets.QLineEdit(entriesPane) + entryStrip.addWidget(self.entryUnitEdit, 1) + self.addEntryButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add to type" + ) + keepSmallHorizontally(self.addEntryButton) + entryStrip.addWidget(self.addEntryButton) + + nestedStrip = QtWidgets.QHBoxLayout() + nestedStrip.setContentsMargins(0, 0, 0, 0) + nestedStrip.addWidget(QtWidgets.QLabel("Nested type:")) + self.nestedTypeCombo = QtWidgets.QComboBox(entriesPane) + nestedStrip.addWidget(self.nestedTypeCombo, 2) + nestedStrip.addWidget(QtWidgets.QLabel("at:")) + self.nestedAtEdit = QtWidgets.QLineEdit(entriesPane) + self.nestedAtEdit.setPlaceholderText("readout") + nestedStrip.addWidget(self.nestedAtEdit, 1) + self.addNestedButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add nested type" + ) + self.addNestedButton.setToolTip("require another Type at that submodule") + keepSmallHorizontally(self.addNestedButton) + nestedStrip.addWidget(self.addNestedButton) + + self.entriesNote = QtWidgets.QLabel(entriesPane) + self.entriesNote.setWordWrap(True) + + entriesLayout.addWidget(self.entriesLabel) + entriesLayout.addWidget(self.entriesView, 1) + entriesLayout.addLayout(entryStrip) + entriesLayout.addLayout(nestedStrip) + entriesLayout.addWidget(self.entriesNote) + + instancesPane = QtWidgets.QWidget(rightPane) + instancesLayout = QtWidgets.QVBoxLayout(instancesPane) + instancesLayout.setContentsMargins(0, 0, 0, 0) + + self.instancesLabel = QtWidgets.QLabel(instancesPane) + self.instancesModel = QtGui.QStandardItemModel(0, 4, self) + self.instancesModel.setHorizontalHeaderLabels( + ["instance", "parameters", "also", ""] + ) + self.instancesView = QtWidgets.QTreeView(instancesPane) + self.instancesView.setModel(self.instancesModel) + self.instancesView.setRootIsDecorated(False) + self.instancesView.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.instancesView.setAlternatingRowColors(True) + instancesHeader = self.instancesView.header() + instancesHeader.setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + for column, width in ( + (1, INSTANCES_COUNT_WIDTH), + (2, INSTANCES_ALSO_WIDTH), + (3, INSTANCES_BUTTON_WIDTH), + ): + instancesHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + instancesHeader.resizeSection(column, width) + + instanceStrip = QtWidgets.QHBoxLayout() + instanceStrip.setContentsMargins(0, 0, 0, 0) + instanceStrip.addWidget(QtWidgets.QLabel("New instance:")) + self.newInstanceEdit = QtWidgets.QLineEdit(instancesPane) + self.newInstanceEdit.setPlaceholderText("q04") + instanceStrip.addWidget(self.newInstanceEdit, 1) + self.addInstanceButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add instance" + ) + keepSmallHorizontally(self.addInstanceButton) + instanceStrip.addWidget(self.addInstanceButton) + self.instancesNote = QtWidgets.QLabel(instancesPane) + + instancesLayout.addWidget(self.instancesLabel) + instancesLayout.addWidget(self.instancesView, 1) + instancesLayout.addLayout(instanceStrip) + instancesLayout.addWidget(self.instancesNote) + + rightPane.addWidget(entriesPane) + rightPane.addWidget(instancesPane) + rightPane.setStretchFactor(0, 3) + rightPane.setStretchFactor(1, 2) + self.splitter.addWidget(typeListPane) + self.splitter.addWidget(rightPane) + self.splitter.setStretchFactor(0, 2) + self.splitter.setStretchFactor(1, 5) + layout.addWidget(self.splitter) + self.setLayout(layout) + + self.addTypeButton.clicked.connect(self._request_add_type) + self.newTypeEdit.returnPressed.connect(self.addTypeButton.click) + self.addEntryButton.clicked.connect(self._request_add_entry) + for edit in (self.entryNameEdit, self.entryDefaultEdit, self.entryUnitEdit): + edit.returnPressed.connect(self.addEntryButton.click) + self.addNestedButton.clicked.connect(self._request_add_nested) + self.nestedAtEdit.returnPressed.connect(self.addNestedButton.click) + self.addInstanceButton.clicked.connect(self._request_add_instance) + self.newInstanceEdit.returnPressed.connect(self.addInstanceButton.click) + self.typeList.selectionModel().currentChanged.connect(self._on_type_selected) + + # ------------------------------------------------------------------ + # rebuilds (plan task 5.5, readings 2-4, 7-8) + # ------------------------------------------------------------------ + + def select_type(self, name: str) -> None: + """Request the selection of the Type ``name``: honoured on the + next rebuild, once the Type is in the state the pane rebuilds + from. Used after the Server call that creates the Type.""" + self.requestedType = name + + def rebuild( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + """Rebuild the three panes from the client-side state (plan task + 5.5): the Type list from ``types`` with Instances counted over + ``parameters``, the entries and Instances panes from the selected + Type, and every row tinted with ``palette``.""" + self._rebuild_type_list(types, parameters, palette) + self._rebuild_selected_panes(types, parameters, palette) + + def _rebuild_type_list( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + names = list(types) + selection_changed = False + if self.requestedType is not None and self.requestedType in types: + selection_changed = self.selectedType != self.requestedType + self.selectedType = self.requestedType + self.requestedType = None + elif self.selectedType not in types: + # the first Type is selected when none is; the selection is + # dropped when the Type is gone + selection_changed = self.selectedType != (names[0] if names else None) + self.selectedType = names[0] if names else None + self.typeModel.removeRows(0, self.typeModel.rowCount()) + current_row = -1 + for row, name in enumerate(names): + count = len(instances_of_type(name, types, parameters)) + name_item = QtGui.QStandardItem(name) + instances_item = QtGui.QStandardItem(str(count)) + params_item = QtGui.QStandardItem(str(len(types[name].effective))) + self.typeModel.appendRow([name_item, instances_item, params_item]) + colours = palette.colours(name) + if colours is not None: + for item in (name_item, instances_item, params_item): + item.setData( + colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole + ) + if name == self.selectedType: + current_row = row + self._building = True + if current_row >= 0: + self.typeList.setCurrentIndex(self.typeModel.index(current_row, 0)) + else: + self.typeList.setCurrentIndex(QtCore.QModelIndex()) + self._building = False + if selection_changed and self.selectedType is not None: + self.typeSelected.emit(self.selectedType) + + def _rebuild_selected_panes( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + selected = self.selectedType or "" + # with no Type selected the labels keep no trailing space and the + # three strips are disabled — their actions all need a Type + # (plan task 5.6) + has_type = bool(selected) + self.entriesLabel.setText( + f"parameters of {selected}" if has_type else "parameters" + ) + self.instancesLabel.setText( + f"instances of {selected}" if has_type else "instances" + ) + self.addEntryButton.setEnabled(has_type) + self.addNestedButton.setEnabled(has_type) + self.addInstanceButton.setEnabled(has_type) + self._rebuild_nested_combo(selected, types) + self._rebuild_entries(selected, types, palette) + self._rebuild_instances(selected, types, parameters, palette) + + def _rebuild_nested_combo( + self, selected: str, types: Mapping[str, PMTypeBluePrint] + ) -> None: + self.nestedTypeCombo.clear() + self.nestedTypeCombo.addItems( + sorted(name for name in types if name != selected) + ) + + def _clear_index_widgets( + self, parent: Optional[QtGui.QStandardItem] = None + ) -> None: + """Delete the row widgets the entries view still hosts, so a + rebuild does not leave the old ones behind.""" + if parent is None: + parent = self.entriesModel.invisibleRootItem() + for row in range(parent.rowCount()): + for column in range(parent.columnCount()): + child = parent.child(row, column) + if child is None: + continue + widget = self.entriesView.indexWidget( + self.entriesModel.indexFromItem(child) + ) + if widget is not None: + widget.deleteLater() + first = parent.child(row, 0) + if first is not None and first.hasChildren(): + self._clear_index_widgets(first) + + def _entry_tint_type( + self, rows: List[EntryRow], index: int, selected: str + ) -> str: + """The Type whose tint an entries row shows: an entry row its + defining Type, a Nested Type row the Type required there, and a + structural submodule row the defining Type of the first entry + below it (the selected Type when that entry is own; the mock's + ``tintsFor(inc ? inc.type : (p.from || selType))``).""" + row = rows[index] + if row.kind == "entry": + return row.from_type or selected + if row.nested_type is not None: + return row.nested_type + for later in rows[index + 1:]: + if later.kind == "entry": + return later.from_type or selected + return selected + + def _rebuild_entries( + self, selected: str, types: Mapping[str, PMTypeBluePrint], palette: TypePalette + ) -> None: + self._clear_index_widgets() + self.entriesModel.removeRows(0, self.entriesModel.rowCount()) + self.entryWidgets = {} + blueprint = types.get(selected) + if selected is None or blueprint is None: + self.entriesView.expandAll() + return + rows = type_entry_rows(selected, types, self.instrument_name) + items_by_path: Dict[str, QtGui.QStandardItem] = {} + for index, row in enumerate(rows): + path = row.path + parent_item = ( + items_by_path[path.rsplit(".", 1)[0]] + if "." in path + else self.entriesModel.invisibleRootItem() + ) + colours = palette.colours(self._entry_tint_type(rows, index, selected)) + name_item = QtGui.QStandardItem(path.split(".")[-1]) + unit_item = QtGui.QStandardItem("" if row.kind == "submodule" else row.unit) + lock_item = QtGui.QStandardItem() + default_item = QtGui.QStandardItem() + parent_item.appendRow([name_item, unit_item, lock_item, default_item]) + items_by_path[path] = name_item + if colours is not None: + for item in (name_item, unit_item, lock_item, default_item): + item.setData( + colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole + ) + entry: Dict[str, Any] = { + "editor": None, + "set": None, + "remove": None, + "toggle": None, + "retarget": None, + "targetLabel": None, + "definedBy": None, + "removeNested": None, + } + self.entryWidgets[path] = entry + if row.kind == "submodule": + self._build_submodule_row( + selected, blueprint, row, lock_item, default_item, entry + ) + else: + self._build_entry_row( + selected, row, lock_item, default_item, entry + ) + self.entriesView.expandAll() + + def _build_submodule_row( + self, + selected: str, + blueprint: PMTypeBluePrint, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + if row.nested_type is not None: + lock_item.setText(f"type: {row.nested_type}") + if row.path in blueprint.nested: + # only the selected Type's OWN Nested Types are removable + nested = blueprint.nested[row.path] + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=self.entriesView.viewport() + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip( + f"stop requiring {nested} here — Instances keep the parameters" + ) + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda type_name=selected, submodule=row.path: self.removeNestedRequested.emit( + type_name, submodule + ) + ) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), remove + ) + entry["removeNested"] = remove + + def _build_entry_row( + self, + selected: str, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + if row.own: + self._build_own_entry_cells(selected, row, lock_item, default_item, entry) + else: + self._build_nested_entry_cells(selected, row, default_item, entry) + + def _build_own_entry_cells( + self, + selected: str, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + # the "locked to" column: the Type Lock toggle, and while the entry + # is locked the re-target button and the Target's relative path + locked = row.target is not None + lock_container = QtWidgets.QWidget(self.entriesView.viewport()) + lock_layout = QtWidgets.QHBoxLayout(lock_container) + lock_layout.setContentsMargins(0, 0, 0, 0) + toggle = make_lock_button(lock_container, locked) + if locked: + toggle.setToolTip( + f"locked to {row.target} — unlock and every Instance of " + f"{selected} goes back to its own value" + ) + else: + toggle.setToolTip( + f"lock — _globals.{selected}.{row.path} is created to hold the " + f"value, and every Instance of {selected} follows it" + ) + toggle.pressed.connect( + lambda type_name=selected, path=row.path: self.toggleTypeLockRequested.emit( + type_name, path + ) + ) + lock_layout.addWidget(toggle) + entry["toggle"] = toggle + if locked: + retarget = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/set.svg"), "", parent=lock_container + ) + retarget.setToolTip( + f"lock every Instance of {selected} to another Target — " + "pick one in the parameter tree" + ) + keepSmallHorizontally(retarget) + retarget.pressed.connect( + lambda type_name=selected, path=row.path: self.retargetTypeLockRequested.emit( + type_name, path + ) + ) + lock_layout.addWidget(retarget) + entry["retarget"] = retarget + target_label = QtWidgets.QLabel(row.target, parent=lock_container) + target_label.setToolTip( + f"{row.target} — followed by every Instance of {selected}" + ) + lock_layout.addWidget(target_label, 1) + entry["targetLabel"] = target_label + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(lock_item), lock_container + ) + + # the default column: the editable default with its set button and + # the entry's Remove button + editor_container = QtWidgets.QWidget(self.entriesView.viewport()) + editor_layout = QtWidgets.QHBoxLayout(editor_container) + editor_layout.setContentsMargins(0, 0, 0, 0) + editor = QtWidgets.QLineEdit(editor_container) + editor.setText("" if row.default is None else str(row.default)) + editor.setPlaceholderText("no default") + set_button = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/set.svg"), "", parent=editor_container + ) + keepSmallHorizontally(set_button) + set_button.pressed.connect( + lambda: self.setDefaultRequested.emit( + selected, row.path, editor.text() + ) + ) + editor.returnPressed.connect(set_button.click) + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=editor_container + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip( + "remove from the Type only — Instances keep the parameter " + "and lose the Type tint" + ) + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda type_name=selected, path=row.path: self.removeEntryRequested.emit( + type_name, path + ) + ) + editor_layout.addWidget(editor, 1) + editor_layout.addWidget(set_button) + editor_layout.addWidget(remove) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), editor_container + ) + entry["editor"] = editor + entry["set"] = set_button + entry["remove"] = remove + + def _build_nested_entry_cells( + self, + selected: str, + row: EntryRow, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + # read-only default text, and "defined by " where the Remove + # button of an own entry would sit + container = QtWidgets.QWidget(self.entriesView.viewport()) + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + default_label = QtWidgets.QLabel( + "" if row.default is None else str(row.default), parent=container + ) + layout.addWidget(default_label, 1) + defined_by = QtWidgets.QLabel(f"defined by {row.from_type}", parent=container) + defined_by.setToolTip( + f"defined by {row.from_type} — change the default there" + ) + layout.addWidget(defined_by) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), container + ) + entry["definedBy"] = defined_by + + def _rebuild_instances( + self, + selected: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + self.instancesModel.removeRows(0, self.instancesModel.rowCount()) + self.showButtons = {} + if not selected: + return + colours = palette.colours(selected) + for instance in instances_of_type(selected, types, parameters): + count = sum( + 1 for path in parameters if path.startswith(f"{instance}.") + ) + also = [ + type_name + for type_name in also_types(instance, types, parameters) + if type_name != selected + ] + name_item = QtGui.QStandardItem(instance) + count_item = QtGui.QStandardItem(f"{count} parameters") + also_item = QtGui.QStandardItem( + f"also {', '.join(also)}" if also else "" + ) + button_item = QtGui.QStandardItem() + self.instancesModel.appendRow( + [name_item, count_item, also_item, button_item] + ) + if colours is not None: + for item in (name_item, count_item, also_item, button_item): + item.setData( + colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole + ) + show = QtWidgets.QPushButton( + "Show", parent=self.instancesView.viewport() + ) + show.setToolTip("show in the parameter tree") + show.pressed.connect( + lambda type_name=selected, node=instance: self.showInstanceRequested.emit( + type_name, node + ) + ) + self.instancesView.setIndexWidget( + self.instancesModel.indexFromItem(button_item), show + ) + self.showButtons[instance] = show + + # ------------------------------------------------------------------ + # selection and strip requests (plan task 5.5, readings 3 and 5) + # ------------------------------------------------------------------ + + @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) + def _on_type_selected( + self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex + ) -> None: + """A row click selects the Type for the other two panes.""" + if self._building or not current.isValid(): + return + name = self.typeModel.item(current.row(), 0) + if name is None or name.text() == self.selectedType: + return + self.selectedType = name.text() + self.requestedType = None + self.typeSelected.emit(name.text()) + + def refresh_selected_panes( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + """Rebuild only the entries and Instances panes, keeping the Type + list as it is: the slot of a user selection.""" + self._rebuild_selected_panes(types, parameters, palette) + + @QtCore.Slot() + def _request_add_type(self) -> None: + name = self.newTypeEdit.text().strip() + if not name: + self.show_type_error("Name must not be empty.") + return + self.addTypeRequested.emit(name) + + @QtCore.Slot() + def _request_add_entry(self) -> None: + path = self.entryNameEdit.text().strip() + if not path: + self.show_entries_error("Name must not be empty.") + return + if self.selectedType is None: + return + # the unit is stripped: matching compares units exactly (D12), and + # a trailing space would make the Type match no Instance + self.addEntryRequested.emit( + self.selectedType, + path, + self.entryDefaultEdit.text(), + self.entryUnitEdit.text().strip(), + ) + + @QtCore.Slot() + def _request_add_nested(self) -> None: + submodule = self.nestedAtEdit.text().strip() + if not submodule: + self.show_entries_error("Submodule must not be empty.") + return + if self.selectedType is None: + return + self.addNestedRequested.emit( + self.selectedType, submodule, self.nestedTypeCombo.currentText() + ) + + @QtCore.Slot() + def _request_add_instance(self) -> None: + name = self.newInstanceEdit.text().strip() + if not name: + self.show_instances_error("Name must not be empty.") + return + if self.selectedType is None: + return + self.addInstanceRequested.emit(self.selectedType, name) + + # ------------------------------------------------------------------ + # note lines (plan task 5.5, reading 5) + # ------------------------------------------------------------------ + + def show_type_error(self, text: str) -> None: + """Show an action error in red on the New type strip's note.""" + self.typeNote.setStyleSheet("QLabel { color: red }") + self.typeNote.setText(text) + + def reset_type_note(self) -> None: + """Restore the New type strip's default note (empty).""" + self.typeNote.setStyleSheet("") + self.typeNote.setText("") + + def show_entries_error(self, text: str) -> None: + """Show an action error in red on the entries pane's note.""" + self.entriesNote.setStyleSheet("QLabel { color: red }") + self.entriesNote.setText(text) + + def show_entries_note(self, text: str) -> None: + """Show ``text`` on the entries pane's note in the normal colour + (a skipped-Lock warning, for example).""" + self.entriesNote.setStyleSheet("") + self.entriesNote.setText(text) + + def reset_entries_note(self) -> None: + """Restore the entries pane's default note (empty).""" + self.entriesNote.setStyleSheet("") + self.entriesNote.setText("") + + def show_instances_error(self, text: str) -> None: + """Show an action error in red on the instances pane's note.""" + self.instancesNote.setStyleSheet("QLabel { color: red }") + self.instancesNote.setText(text) + + def reset_instances_note(self) -> None: + """Restore the instances pane's default note (empty).""" + self.instancesNote.setStyleSheet("") + self.instancesNote.setText("") diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py new file mode 100644 index 0000000..3c64d82 --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -0,0 +1,1522 @@ +"""The Parameter Manager widget (:class:`ParameterManagerGui`) and the +model, tree view and create form it is built from.""" + +import logging +from typing import ( + Any, + Dict, + Optional, + Tuple, + Union, + cast, +) + +from ... import QtCore, QtGui, QtWidgets +from ...blueprints import ( + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, +) +from ...client import ProxyInstrument +from ...helpers import nestedAttributeFromString +from ...params import ( + ParameterManager, + ParameterTypes, + parameterTypes, + paramTypeFromName, +) +from .. import keepSmallHorizontally +from ..base_instrument import InstrumentTreeViewBase +from ..instruments import ( + InstrumentParameters, + ModelParameters, + ParameterDelegate, + ValueCellNavigationFilter, +) +from ..parameters import AnyInput, ParameterWidget +from .logic import ( + GUTTER_COLUMN, + GUTTER_ROLE, + GUTTER_WIDTH, + LOCK_COLUMN, + LOCK_COLUMN_WIDTH, + Claim, + PMState, + TypePalette, + _lock_row_paths, + build_lock_rows, + compute_claims, + followers_reaching, + lock_button_tooltip, + lock_column_text, + parse_default_text, + rank_lock_targets, + relative_path, +) +from .panels import ( + GutterDelegate, + LockArmStrip, + LocksPanel, + TypesPane, + make_lock_button, +) + +logger = logging.getLogger(__name__) + + +class AddParameterWidget(QtWidgets.QWidget): + """A widget that allows parameter creation. + + :param parent: parent widget + :param typeInput: if ``True``, add input fields for creating a value + validator. + """ + + #: Signal(str, str, str, ParameterTypes, str) + newParamRequested = QtCore.Signal(str, str, str, ParameterTypes, str) + + #: Signal(str) + invalidParamRequested = QtCore.Signal(str) + + def __init__( + self, parent: Optional[QtWidgets.QWidget] = None, typeInput: bool = False + ) -> None: + super().__init__(parent) + + self.typeInput = typeInput + + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.nameEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Name:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 0, 0) + layout.addWidget(self.nameEdit, 0, 1) + + self.valueEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Value:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 0, 2) + layout.addWidget(self.valueEdit, 0, 3) + + self.unitEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Unit:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 0, 4) + layout.addWidget(self.unitEdit, 0, 5) + + if typeInput: + self.typeSelect = QtWidgets.QComboBox(self) + names: list[str] = [] + for t, v in parameterTypes.items(): + names.append(str(v["name"])) + for n in sorted(names): + self.typeSelect.addItem(n) + self.typeSelect.setCurrentText( + str(parameterTypes[ParameterTypes.numeric]["name"]) + ) + lbl = QtWidgets.QLabel("Type:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 1, 0) + layout.addWidget(self.typeSelect, 1, 1) + + self.valsArgsEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Type opts.:") + lbl.setToolTip( + "Optional, for constraining parameter values." + "Allowed args and defaults:\n" + " - 'Numeric': min_value=-1e18, max_value=1e18\n" + " - 'Integer': min_value=-inf, max_value=inf\n" + " - 'String': min_length=0, max_length=1e9\n" + "See qcodes.utils.validators for details." + ) + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 1, 2) + layout.addWidget(self.valsArgsEdit, 1, 3) + + self.addButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), " Add", parent=self + ) + + self.addButton.clicked.connect(self.requestNewParameter) + self.nameEdit.returnPressed.connect(self.addButton.click) + self.valueEdit.returnPressed.connect(self.addButton.click) + self.unitEdit.returnPressed.connect(self.addButton.click) + layout.addWidget(self.addButton, 0, 6, 1, 1) + self.addButton.setAutoDefault(True) + + self.clearButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), " Clear", parent=self + ) + + self.clearButton.setAutoDefault(True) + self.clearButton.clicked.connect(self.clear) + layout.addWidget(self.clearButton, 0, 7, 1, 1) + + self.setLayout(layout) + self.invalidParamRequested.connect(self.setError) + + @QtCore.Slot() + def clear(self) -> None: + self.clearError() + self.nameEdit.setText("") + self.valueEdit.setText("") + self.unitEdit.setText("") + if self.typeInput: + self.typeSelect.setCurrentText( + parameterTypes[ParameterTypes.numeric]["name"] # type: ignore[arg-type] + ) + self.valsArgsEdit.setText("") + + @QtCore.Slot(bool) + def requestNewParameter(self, _: bool) -> None: + self.clearError() + + name = self.nameEdit.text().strip() + if len(name) == 0: + self.invalidParamRequested.emit("Name must not be empty.") + return + value = self.valueEdit.text() + unit = self.unitEdit.text() + + if hasattr(self, "typeSelect"): + ptype = paramTypeFromName(self.typeSelect.currentText()) + valsArgs = self.valsArgsEdit.text() + else: + ptype = ParameterTypes.any + valsArgs = "" + + self.newParamRequested.emit(name, value, unit, ptype, valsArgs) + + @QtCore.Slot(str) + def setError(self, message: str) -> None: + self.addButton.setStyleSheet(""" + QPushButton { background-color: red } + """) + self.addButton.setToolTip(message) + + def clearError(self) -> None: + self.addButton.setStyleSheet("") + self.addButton.setToolTip("") + + +class ModelParameterManager(ModelParameters): + #: Signal() -- + #: Emitted after a Broadcast changed the tree's structure: a parameter + #: was created or removed, or a ``parameter-update``/``parameter-call`` + #: added a row the model did not know. The Parameter Manager GUI + #: recomputes the Type claims that the tints and gutter bands show. + structureChanged = QtCore.Signal() + + #: Signal(str, object) -- + #: Emitted on a ``pm-lock-update`` Broadcast: the Follower's path relative + #: to the instrument, and its :class:`PMLockBluePrint` (``None`` when its + #: Lock was removed). No model item is touched for this action. + lockChanged = QtCore.Signal(str, object) + + #: Signal(str, object) -- + #: Emitted on a ``pm-type-update`` Broadcast: the Type's name (the part + #: after the instrument name), and its :class:`PMTypeBluePrint` (``None`` + #: when the Type was removed). No model item is touched for this action. + typeChanged = QtCore.Signal(str, object) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # ModelParameters pins the column count at 3 after loading; widen it + # again and give every loaded row the gutter item the narrow count + # dropped, and the Lock column item (plan task 5.3) + self.setColumnCount(LOCK_COLUMN + 1) + self.setHorizontalHeaderLabels([self.attr, "unit", "", "", "locked to"]) + self._ensure_extra_items(self.invisibleRootItem()) + + def _ensure_extra_items(self, parent: QtGui.QStandardItem) -> None: + """Give every row under ``parent`` its gutter item and its Lock + column item.""" + for row in range(parent.rowCount()): + for column in (GUTTER_COLUMN, LOCK_COLUMN): + if parent.child(row, column) is None: + parent.setChild(row, column, QtGui.QStandardItem()) + item = parent.child(row, 0) + if item is not None and item.hasChildren(): + self._ensure_extra_items(item) + + def insertItemTo( + self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem + ) -> None: + if item is not None: + # A parameter might not have a unit + unit = "" + if item.element is not None: # type: ignore[attr-defined] + unit = item.element.unit # type: ignore[attr-defined] + unitItem = QtGui.QStandardItem(unit) + extraItem = QtGui.QStandardItem() + gutterItem = QtGui.QStandardItem() + lockItem = QtGui.QStandardItem() + + if parent == self: + rowCount = self.rowCount() + self.setItem(rowCount, 0, item) + self.setItem(rowCount, 1, unitItem) + self.setItem(rowCount, 2, extraItem) + self.setItem(rowCount, GUTTER_COLUMN, gutterItem) + self.setItem(rowCount, LOCK_COLUMN, lockItem) + else: + parent.appendRow([item, unitItem, extraItem, gutterItem, lockItem]) + + self.newItem.emit(item) + + def _has_row(self, full_name: str) -> bool: + """Whether the model holds a row for the dotted path ``full_name`` + (the Broadcast name with the instrument name stripped).""" + return bool( + self.findItems( + full_name, + cast( + "QtCore.Qt.MatchFlags", + QtCore.Qt.MatchFlag.MatchExactly + | QtCore.Qt.MatchFlag.MatchRecursive, + ), + 0, + ) + ) + + def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: + fullName = ".".join(bp.name.split(".")[1:]) + if bp.action == PM_LOCK_UPDATE: + # Locks and Types claim no model item of their own: the + # Parameter Manager GUI records the change in its PMState (D10) + self.lockChanged.emit(fullName, bp.value) + return + if bp.action == PM_TYPE_UPDATE: + self.typeChanged.emit(fullName, bp.value) + return + value_update = bp.action in (PARAMETER_UPDATE, PARAMETER_CALL) + known_row = value_update and self._has_row(fullName) + super().updateParameter(bp) + # a parameter-update or parameter-call for a row the model did not + # know adds one through the base update branch; matching depends + # on which parameters exist, so the tints and gutter bands must be + # recomputed for it too (plan task 5.6), or the new row would + # stay untinted until the next recompute + added_row = value_update and not known_row and self._has_row(fullName) + if bp.action in (PARAMETER_CREATION, PARAMETER_DELETION) or added_row: + self.structureChanged.emit() + + +class ParameterDeleteDelegate(ParameterDelegate): + #: Signal(str) + #: Emits the name of the parameter to be deleted when the user presses the delete button. + removeParameter = QtCore.Signal(str) + + #: Signal(str) + #: Emits the name of the parameter whose lock button the user pressed; + #: the Parameter Manager GUI toggles that parameter's Lock. + toggleLock = QtCore.Signal(str) + + def createEditor( # type: ignore[override] + self, + widget: QtWidgets.QWidget, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> QtWidgets.QWidget: + item = self.getItem(index) + + if not item.showDelegate: # type: ignore[attr-defined] + return None # type: ignore[return-value] + + element = item.element # type: ignore[attr-defined] + rw = self.makeRemoveWidget(item.name, widget) # type: ignore[attr-defined] + lw = self.make_lock_widget(item.name, widget) + + ret = ParameterWidget( + parameter=element, parent=widget, additionalWidgets=[lw, rw] + ) + # the lock button is kept on the row's ParameterWidget so the + # Parameter Manager GUI can restyle it with the Lock state + ret.lockButton = lw + self.parameters[item.name] = ret # type: ignore[attr-defined] + ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] + + if self.navFilter is not None: + if isinstance(ret.paramWidget, AnyInput): + input_widget = ret.paramWidget.input + else: + input_widget = ret.paramWidget + input_widget.installEventFilter(self.navFilter) + self.navFilter.registerWidget(input_widget, index) + + return ret + + def make_lock_widget( + self, fullName: str, widget: QtWidgets.QWidget + ) -> QtWidgets.QPushButton: + """The per-row lock button. It stays hidden until the row carries a + Lock (a Lock-less row shows no button, as the mock), fills purple + while the Lock is locked, and only :meth:`ParameterManagerGui. + apply_locks` changes its state.""" + w = make_lock_button(widget, locked=False) + w.setVisible(False) + + w.pressed.connect(lambda: self.toggleLock.emit(fullName)) + return w + + def makeRemoveWidget( + self, fullName: str, widget: QtWidgets.QWidget + ) -> QtWidgets.QPushButton: + w = QtWidgets.QPushButton(QtGui.QIcon(":/icons/delete.svg"), "", parent=widget) + w.setStyleSheet(""" + QPushButton { background-color: salmon } + """) + w.setToolTip("Delete this parameter") + keepSmallHorizontally(w) + + w.pressed.connect(lambda: self.removeParameter.emit(fullName)) + return w + + +# TODO: Make sure that the refresh button refreshes the profiles as well as the model +class ParameterManagerTreeView(InstrumentTreeViewBase): + #: Signal(str) + #: Emitted when the user picks "Lock to…" in the context menu; the + #: Parameter Manager GUI arms the target picker for that parameter. + lockToRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the user picks "Unlock" in the context menu. + unlockRequested = QtCore.Signal(str) + + def __init__( + self, + model: QtCore.QAbstractItemModel, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(model, [2], *args, **kwargs) + + self.delegate = ParameterDeleteDelegate(self) + self.delegate.navFilter = ValueCellNavigationFilter(self) + + self.setItemDelegateForColumn(2, self.delegate) + + # the gutter column exists only in the Parameter Manager's own model + # (ModelParameterManager) + self.gutterDelegate = GutterDelegate(self) + if self.model().columnCount() > GUTTER_COLUMN: + self.setItemDelegateForColumn(GUTTER_COLUMN, self.gutterDelegate) + header = self.header() + # the gutter moves to visual position 0 with a fixed width; the + # tree branches stay on the name column + header.moveSection(GUTTER_COLUMN, 0) + if header.minimumSectionSize() > GUTTER_WIDTH: + header.setMinimumSectionSize(GUTTER_WIDTH) + header.setSectionResizeMode( + GUTTER_COLUMN, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + header.resizeSection(GUTTER_COLUMN, GUTTER_WIDTH) + if self.model().columnCount() > LOCK_COLUMN: + # the Lock column moves between the unit and the delegate + # column, with a resizable default width + header.moveSection( + header.visualIndex(LOCK_COLUMN), header.visualIndex(2) + ) + header.setSectionResizeMode( + LOCK_COLUMN, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + header.resizeSection(LOCK_COLUMN, LOCK_COLUMN_WIDTH) + self.setTreePosition(0) + self.setAllDelegatesPersistent() + + # the lock actions act on the row the context menu was opened for + # (self.lastSelectedItem, set by the base onContextMenuRequested + # before the menu opens); the Parameter Manager GUI enables and + # disables them in its aboutToShow slot + self.lockToAction = QtWidgets.QAction("Lock to…") + self.lockToAction.triggered.connect(self._on_lock_to_action_trigger) + self.unlockAction = QtWidgets.QAction("Unlock") + self.unlockAction.triggered.connect(self._on_unlock_action_trigger) + self.contextMenu.addSeparator() + self.contextMenu.addAction(self.lockToAction) + self.contextMenu.addAction(self.unlockAction) + + @QtCore.Slot() + def _on_lock_to_action_trigger(self) -> None: + """The context menu's "Lock to…": arm the target picker for the + row's parameter; a submodule row has no Lock to arm.""" + item = self.lastSelectedItem + if item is not None and item.element is not None: + self.lockToRequested.emit(item.name) + + @QtCore.Slot() + def _on_unlock_action_trigger(self) -> None: + """The context menu's "Unlock": unlock the row's Lock; a submodule + row has no Lock to unlock.""" + item = self.lastSelectedItem + if item is not None and item.element is not None: + self.unlockRequested.emit(item.name) + + @QtCore.Slot(object, object) + def onItemNewValue(self, itemName: str, value: Any) -> None: + widget = self.delegate.parameters[itemName] + try: + # use the abstract set method defined in parameter widget so it works for different types of widgets + widget._setMethod(value) + except RuntimeError: + logger.debug( + f"Could not set value for {itemName} to {value}. Object is not being shown right now." + ) + + +class ProfilesManager(QtWidgets.QComboBox): + #: Signal() + #: Emitted when the selected index changed. + indexChanged = QtCore.Signal() + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + self.setEditable(False) + self.params = self.parent().instrument # type: ignore[union-attr] + self.refreshing = False + + loadingProfile = None + for profile in self.params.list_profiles(): + self.addItem(self.params.cleanProfileName(profile)) + if loadingProfile is None: + loadingProfile = profile + + self.currentIndexChanged.connect(self.onCurrentIndexChanged) + + def refresh(self) -> None: + self.refreshing = True + currentlySelected = self.currentText() + self.clear() + for profile in self.params.list_profiles(): + self.addItem(self.params.cleanProfileName(profile)) + if self.params.cleanProfileName(profile) == currentlySelected: + self.setCurrentIndex(self.count() - 1) + self.refreshing = False + + @QtCore.Slot(int) + def onCurrentIndexChanged(self, index: int) -> None: + if not self.refreshing: + self.indexChanged.emit() + + +class ParameterManagerGui(InstrumentParameters): + #: Signal(str) -- + #: emitted when there's an error during parameter creation. + parameterCreationError = QtCore.Signal(str) + + #: Signal() -- + #: emitted when a parameter was created successfully + parameterCreated = QtCore.Signal() + + def __init__( + self, + instrument: Union[ProxyInstrument, ParameterManager], + parent: Optional[QtWidgets.QWidget] = None, + **kwargs: Any, + ) -> None: + super().__init__( + instrument, + parent=None, + viewType=ParameterManagerTreeView, + callSignals=False, + modelType=ModelParameterManager, + **kwargs, + ) + # The client-side cache of the Parameter Manager's Types and Locks. + # Created before connectSignals, which wires the model's Broadcast + # routing into it. + self.state = PMState() + # The tint palette: maps each Type to its slot in TINT_PALETTE; the + # view's gutter delegate reads the colours from it. + self.typePalette = TypePalette() + self.view.gutterDelegate.typePalette = self.typePalette + self.profileManager = ProfilesManager(parent=self) + self.addParam = AddParameterWidget(parent=self) + layout = self.layout() + assert isinstance(layout, QtWidgets.QVBoxLayout) + layout.insertWidget(0, self.profileManager) + layout.addWidget(self.addParam) + # The Locks panel (plan task 5.4) sits right of the tree in a + # splitter: the view keeps its identity, so every existing layout + # consumer and test keeps working. The panel starts hidden and + # costs nothing until the toolbar action shows it. + self.locksPanel = LocksPanel(self.instrument.name, parent=self) + view_index = layout.indexOf(self.view) + layout.removeWidget(self.view) + self.locksSplitter = QtWidgets.QSplitter( + QtCore.Qt.Orientation.Horizontal, self + ) + self.locksSplitter.addWidget(self.view) + self.locksSplitter.addWidget(self.locksPanel) + self.locksSplitter.setStretchFactor(0, 3) + self.locksSplitter.setStretchFactor(1, 2) + layout.insertWidget(view_index, self.locksSplitter) + self.locksPanel.setVisible(False) + # The existing content becomes tab 0 of the tab widget; tab 1 + # holds the Types pane (plan task 5.5). + self.parametersTab = QtWidgets.QWidget(self) + self.parametersTab.setLayout(self.layout()) + self.typesTab = QtWidgets.QWidget(self) + typesTabLayout = QtWidgets.QVBoxLayout(self.typesTab) + typesTabLayout.setContentsMargins(0, 0, 0, 0) + self.typesPane = TypesPane(self.instrument.name, parent=self.typesTab) + typesTabLayout.addWidget(self.typesPane) + self.tabs = QtWidgets.QTabWidget(self) + self.tabs.addTab(self.parametersTab, "Parameters") + self.tabs.addTab(self.typesTab, "Types") + outerLayout = QtWidgets.QVBoxLayout(self) + outerLayout.setContentsMargins(0, 0, 0, 0) + outerLayout.addWidget(self.tabs) + # The arm strip sits right under the toolbar and stays hidden until + # a Lock's Target is being picked (plan task 5.3). The Follower the + # pick is armed for is kept here, and — for the Types tab's Type + # Lock re-target (plan task 5.5) — the (Type, entry) pair the + # re-target is armed for. + self.armed_follower: Optional[str] = None + self.armed_type_lock: Optional[Tuple[str, str]] = None + self.armStrip = LockArmStrip(self.parametersTab) + parametersLayout = self.parametersTab.layout() + assert isinstance(parametersLayout, QtWidgets.QVBoxLayout) + toolbar_index = parametersLayout.indexOf(self.toolbar) + parametersLayout.insertWidget(toolbar_index + 1, self.armStrip) + self.armStrip.setVisible(False) + # Escape over the tree cancels the pick too (harmless when the + # strip is not armed) + self.viewEscShortcut = QtWidgets.QShortcut( + QtGui.QKeySequence("Escape"), self.view + ) + self.viewEscShortcut.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) + self.viewEscShortcut.activated.connect(self.cancel_arm) + # The confirmation dialog for removing a Lock Target (plan task + # 5.6), kept on the GUI so tests can drive it; ``None`` while no + # removal that needs one is in flight. + self.removalDialog: Optional[QtWidgets.QMessageBox] = None + self.connectSignals() + self.loadProfile() + + def connectSignals(self) -> None: + super().connectSignals() + self.view.delegate.removeParameter.connect(self.removeParameter) + self.view.delegate.toggleLock.connect(self._toggle_lock) + self.addParam.newParamRequested.connect(self.addParameter) + self.parameterCreationError.connect(self.addParam.setError) + self.parameterCreated.connect(self.addParam.clear) + self.profileManager.indexChanged.connect(self.loadProfile) + self.model.lockChanged.connect(self._on_lock_changed) + self.model.typeChanged.connect(self._on_type_changed) + self.model.structureChanged.connect(self.apply_tints) + self.model.structureChanged.connect(self.apply_locks) + self.model.itemNewValue.connect(self._on_item_new_value) + # the filter (and the trash toggle) hides rows; when they come + # back, restoreCollapsedDict has re-opened their persistent + # editors, so createEditor has built fresh ParameterWidgets whose + # lock button is hidden and whose input is editable — re-apply the + # Lock state to them + self.proxyModel.filterFinished.connect(self.apply_locks) + self.view.lockToRequested.connect(self.arm_lock) + self.view.unlockRequested.connect(self._unlock) + self.view.contextMenu.aboutToShow.connect(self._update_lock_actions) + self.view.clicked.connect(self._on_view_clicked) + self.armStrip.targetPicked.connect(self.pick_lock_target) + self.armStrip.cancelled.connect(self.cancel_arm) + # the Locks panel (plan task 5.4): its actions run through this GUI, + # and the tree's current row drives the panel's selected label + self.locksAction.toggled.connect(self._on_locks_action_toggled) + self.locksPanel.toggleLockRequested.connect(self._on_panel_toggle_lock) + self.locksPanel.removeLockRequested.connect(self._on_panel_remove_lock) + self.locksPanel.lockAllRequested.connect(self._on_panel_lock_all) + self.locksPanel.removeRuleRequested.connect(self._on_panel_remove_rule) + self.locksPanel.lockSelectionRequested.connect( + self._lock_selection_from_panel + ) + self.view.selectionModel().currentChanged.connect( + self._on_tree_current_changed + ) + # the Types pane (plan task 5.5): its actions run through this GUI, + # and a selection change re-renders the two panes it drives + self.typesPane.typeSelected.connect(self._on_pane_type_selected) + self.typesPane.addTypeRequested.connect(self._on_pane_add_type) + self.typesPane.addEntryRequested.connect(self._on_pane_add_entry) + self.typesPane.removeEntryRequested.connect(self._on_pane_remove_entry) + self.typesPane.setDefaultRequested.connect(self._on_pane_set_default) + self.typesPane.toggleTypeLockRequested.connect( + self._on_pane_toggle_type_lock + ) + self.typesPane.retargetTypeLockRequested.connect(self.arm_type_lock) + self.typesPane.addNestedRequested.connect(self._on_pane_add_nested) + self.typesPane.removeNestedRequested.connect(self._on_pane_remove_nested) + self.typesPane.addInstanceRequested.connect(self._on_pane_add_instance) + self.typesPane.showInstanceRequested.connect(self._on_pane_show_instance) + self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) + self.shortcutManager.register("clear_add", self.addParam.clear, self) + self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) + self.shortcutManager.register("load_items", self.loadFromFile, self) + self.shortcutManager.register("save_items", self.saveToFile, self) + self.shortcutManager.register("toggle_locks", self.locksAction.toggle, self) + # the Lock shortcuts (plan task 5.6); the tree's two lock actions + # carry their key in their tooltips + self.shortcutManager.register("lock_to", self._lock_current_item, self) + self.shortcutManager.register("unlock_item", self._unlock_current_item, self) + self.shortcutManager.register("show_types", self._toggle_tabs, self) + self.shortcutManager.register_tooltip("lock_to", self.view.lockToAction) + self.shortcutManager.register_tooltip("unlock_item", self.view.unlockAction) + + @QtCore.Slot() + def _deleteCurrentItem(self) -> None: + item = self._getCurrentItem() + if item is not None: + self.removeParameter(item.name) + + def makeToolbar(self) -> QtWidgets.QToolBar: + toolbar = super().makeToolbar() + + toolbar.addSeparator() + + loadParamAction = toolbar.addAction( + QtGui.QIcon(":/icons/load.svg"), + "Load parameters from file", + ) + loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("load_items", loadParamAction) + + saveParamAction = toolbar.addAction( + QtGui.QIcon(":/icons/save.svg"), + "Save parameters to file", + ) + saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("save_items", saveParamAction) + + # the Locks panel toggle (plan task 5.4); the toggled connection + # and the shortcut are wired in connectSignals, where the panel + # exists + self.locksAction = toolbar.addAction( + QtGui.QIcon(":/icons/lock.svg"), + "Show the Locks panel", + ) + self.locksAction.setCheckable(True) + self.shortcutManager.register_tooltip("toggle_locks", self.locksAction) + + return toolbar + + def refreshAll(self) -> None: + super().refreshAll() + self.instrument.refresh_profiles() + self.profileManager.refresh() + self.state.refresh(self.instrument) + self.apply_tints() + self.apply_locks() + + def removeParameter(self, fullName: str) -> None: + """Remove the parameter at ``fullName`` (the row's delete button + and the delete_item shortcut both land here). + + While the parameter is the Target of Locks — deleting it drops + them (D3) — a QMessageBox names every Follower that will lose its + Lock and asks for confirmation (plan task 5.6); Cancel returns + without touching the Server. A parameter without Followers is + removed without a dialog.""" + self.removalDialog = None + if not self.instrument.has_param(fullName): + return + try: + followers = self.instrument.followers_of(fullName) + except Exception: + # the Server call failed: fall back to the client-side list + # computed from the state — locked and unlocked alike, the + # Targets compared through relative_path + followers = sorted( + follower + for follower, lock in self.state.locks.items() + if relative_path(lock.target, self.instrument.name) == fullName + ) + if followers: + lines = [] + for follower in followers: + lock = self.state.locks.get(follower) + state = "locked" if lock is not None and lock.locked else "unlocked" + lines.append(f"{follower} ({state})") + box = QtWidgets.QMessageBox(self) + box.setObjectName("removalDialog") + box.setIcon(QtWidgets.QMessageBox.Icon.Question) + box.setWindowTitle("Remove Target?") + # macOS ignores a QMessageBox's window title (windowTitle() + # reads back empty there); tests pin the dialog through its + # object name and text instead. + box.setText( + f"Removing {fullName} also removes the Locks of:\n" + + "\n".join(lines) + ) + box.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel + ) + box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + self.removalDialog = box + clicked = box.exec() + # the box is closed on both paths: the attribute matches its + # docstring again (the tests' QTimer callbacks read it while + # the box is open, so they keep working) + self.removalDialog = None + if clicked != QtWidgets.QMessageBox.StandardButton.Ok: + return + self.instrument.remove_parameter(fullName) + + def addParameter(self, fullName: str, value: Any, unit: str) -> None: + try: + # Validators are commented out until they can be serialized. + self.instrument.add_parameter( + fullName, + initial_value=value, + unit=unit, + ) # vals=vals) + self.parameterCreated.emit() + except Exception as e: + self.parameterCreationError.emit( + f"Could not create parameter.Adding parameter raised{type(e)}: {e.args}" + ) + return + + @QtCore.Slot() + def loadProfile(self) -> None: + profileName = self.profileManager.currentText() + self.instrument.switch_to_profile(profileName) + super().refreshAll() + self.instrument.refresh_profiles() + # a profile load emits no parameter-creation/parameter-deletion + # Broadcasts for the parameters it (re)creates, so the state of the + # Types and Locks must be re-read from the Parameter Manager + self.state.refresh(self.instrument) + self.apply_tints() + self.apply_locks() + + @QtCore.Slot(str, object) + def _on_type_changed( + self, name: str, type_blueprint: Optional[PMTypeBluePrint] + ) -> None: + """Record the change a ``pm-type-update`` Broadcast reports about + the Type ``name`` in the state, then recompute the tints and gutter + bands it may change, and rebuild the Locks panel (its Type Lock + rows depend on the Types).""" + self.state.apply_type(name, type_blueprint) + self.apply_tints() + self.refresh_locks_panel() + + @QtCore.Slot(str, object) + def _on_lock_changed( + self, path: str, lock: Optional[PMLockBluePrint] + ) -> None: + """Record the change a ``pm-lock-update`` Broadcast reports about + the Follower at ``path``, then recompute the Lock column and the + row widgets, and repaint the values the change alters: the + Follower's own and every row whose chain of locked Locks reaches + it, since locking and unlocking change what ``get`` answers — in + the tree and, while it is shown, in the Locks panel.""" + self.state.apply_lock(path, lock) + self.apply_locks() + refreshed = [ + path, + *followers_reaching(path, self.state.locks, self.instrument.name), + ] + for follower in refreshed: + self._refresh_row_widget(follower) + if not self.locksPanel.isHidden(): + self.locksPanel.refresh_values(refreshed) + + @QtCore.Slot(object, object) + def _on_item_new_value(self, path: object, value: object) -> None: + """Repaint every Follower whose locked Lock chain reaches the + parameter a ``parameter-update`` Broadcast names (D3: a locked + Follower answers ``get`` with the Target's value, and the + Parameter Manager emits nothing for values). The Broadcast's own + row is refreshed by the base wiring to + ``view.onItemNewValue``; this slot handles the rows behind it — + and, while the Locks panel is shown, the same paths there.""" + followers = followers_reaching( + str(path), self.state.locks, self.instrument.name + ) + for follower in followers: + self._refresh_row_widget(follower) + if not self.locksPanel.isHidden(): + self.locksPanel.refresh_values([str(path), *followers]) + + def _refresh_row_widget(self, path: str) -> None: + """Re-read the parameter behind the row at ``path`` through the + Proxy, which pulls the Target's value for a locked Follower, and + show it on the row's widget.""" + widget = self.view.delegate.parameters.get(path) + if widget is None: + return + try: + widget.setWidgetFromParameter() + except RuntimeError: + logger.debug( + f"Could not refresh the value of {path}. " + "Object is not being shown right now." + ) + + @QtCore.Slot() + def apply_locks(self) -> None: + """Recompute every parameter row's Lock state from the client-side + state (plan task 5.3): the Lock column text, the lock button's + visibility, tooltip and purple fill, and whether the value renders + read-only. + + Runs after the state was refreshed from the Parameter Manager (on a + model reload), on every ``pm-lock-update`` Broadcast, and after a + parameter was created or removed by a Broadcast. Recomputing all + rows on every change is fine — the tree is small — and keeps one + clear path. The Locks panel is rebuilt with the same state at the + end, but only while it is shown.""" + self._apply_locks_to_rows(self.model.invisibleRootItem()) + self.refresh_locks_panel() + + def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: + """Walk the source model (never the proxy) and set each row's Lock + column text, lock button state and read-only flag.""" + for row in range(parent.rowCount()): + item = parent.child(row, 0) + if item is None: + continue + lockItem = parent.child(row, LOCK_COLUMN) + if lockItem is None: + lockItem = QtGui.QStandardItem() + parent.setChild(row, LOCK_COLUMN, lockItem) + if item.element is None: + # a submodule row carries no Lock state of its own + lockItem.setText("") + else: + lockItem.setText( + lock_column_text( + item.name, self.state.locks, self.instrument.name + ) + ) + widget = self.view.delegate.parameters.get(item.name) + if widget is not None: + self._update_row_lock_widget(item.name, widget) + if item.hasChildren(): + self._apply_locks_to_rows(item) + + def _update_row_lock_widget( + self, path: str, widget: "ParameterWidget" + ) -> None: + """Set one row's lock button and read-only state from the Lock the + state holds for ``path``. A row without a Lock shows no button and + renders its value editable.""" + button = getattr(widget, "lockButton", None) + lock = self.state.locks.get(path) + if lock is None: + if button is not None: + button.setVisible(False) + widget.set_read_only(False) + return + target = relative_path(lock.target, self.instrument.name) + tooltip = lock_button_tooltip(lock.locked, target) + if button is not None: + button.setToolTip(tooltip) + button.setProperty("locked", lock.locked) + # re-polish so the locked property restyles the button + button.style().unpolish(button) + button.style().polish(button) + button.setVisible(True) + widget.set_read_only(lock.locked) + + @QtCore.Slot(str) + def _toggle_lock(self, path: str) -> None: + """Toggle the Lock of the parameter at ``path`` (the row's lock + button). A refused toggle — relocking would close a cycle (D7) — + shows the Server's error text on the row's alert widget.""" + widget = self.view.delegate.parameters.get(path) + try: + self.instrument.toggle_lock(path) + except Exception as e: + if widget is not None: + widget.alertWidget.setAlert(str(e)) + + @QtCore.Slot(str) + def _unlock(self, path: str) -> None: + """Unlock the Lock of the parameter at ``path`` (the context + menu's "Unlock"). A refused unlock shows the Server's error text + on the row's alert widget.""" + widget = self.view.delegate.parameters.get(path) + try: + self.instrument.unlock(path) + except Exception as e: + if widget is not None: + widget.alertWidget.setAlert(str(e)) + + def _lock_current_item(self) -> None: + """The lock_to shortcut (plan task 5.6): arm the target picker for + the tree's current parameter row. A submodule row or no selection + does nothing.""" + item = self._getCurrentItem() + if item is not None and item.element is not None: + self.arm_lock(item.name) + + def _unlock_current_item(self) -> None: + """The unlock_item shortcut (plan task 5.6): unlock the Lock of + the tree's current parameter row while it is locked; a row + without a locked Lock does nothing. A refused unlock shows the + Server's error text on the row's alert widget, like the context + menu's Unlock.""" + item = self._getCurrentItem() + if item is None or item.element is None: + return + lock = self.state.locks.get(item.name) + if lock is None or not lock.locked: + return + self._unlock(item.name) + + def _toggle_tabs(self) -> None: + """The show_types shortcut (plan task 5.6): switch between the + Parameters and Types tabs.""" + self.tabs.setCurrentIndex(1 if self.tabs.currentIndex() == 0 else 0) + + @QtCore.Slot() + def _update_lock_actions(self) -> None: + """Enable the context menu's lock actions for the row the menu was + opened on: "Lock to…" for every parameter row, "Unlock" only for a + parameter whose Lock in the state is locked.""" + item = self.view.lastSelectedItem + is_parameter = item is not None and item.element is not None + self.view.lockToAction.setEnabled(is_parameter) + self.view.unlockAction.setEnabled( + is_parameter + and item.name in self.state.locks # type: ignore[union-attr] + and self.state.locks[item.name].locked # type: ignore[union-attr] + ) + + def arm_lock(self, follower: str) -> None: + """Arm the target picker for the Follower at ``follower``: the + candidates are every other parameter row of the source model, + ranked like the mock's completer (same relative path inside its + Instance first), and the strip shows under the toolbar. Arming + while already armed re-arms for the new Follower.""" + parameters = self._model_parameters() + claims = compute_claims(self.state.types, parameters) + self.armed_follower = follower + self.armed_type_lock = None + self.armStrip.arm(follower, rank_lock_targets(follower, parameters, claims)) + + def arm_type_lock(self, type_name: str, path: str) -> None: + """Arm the target picker for the Type Lock of the entry ``path`` + of the Type ``type_name`` (plan task 5.5): the strip shows on the + Parameters tab with the entry named in its label, and the + candidates are every parameter row of the source model ranked + with the entry path as the relative path (the same leaf on any + Instance first). Arming while already armed re-arms for the new + entry.""" + self.armed_type_lock = (type_name, path) + self.armed_follower = None + self.tabs.setCurrentIndex(0) + parameters = self._model_parameters() + claims = compute_claims(self.state.types, parameters) + self.armStrip.arm( + f"type {type_name} \u00b7 {path}", + rank_lock_targets("", parameters, claims, arm_rel=path), + ) + + def pick_lock_target(self, target: str) -> None: + """Pick ``target`` as the Target of the armed pick — a Follower's + Lock (plan task 5.3) or, while a Type Lock re-target is armed, the + Type Lock declaration with ``target`` as its Target (plan task + 5.5). A refused Lock — a cycle (D7), a self-lock — shows the + Server's error text on the strip and stays armed so another + target can be picked; a successful Lock disarms the strip, and a + Type Lock declaration names the Instance parameters it skipped + (D17) on the entries pane's note.""" + if self.armed_type_lock is not None: + type_name, path = self.armed_type_lock + try: + skipped = self.instrument.lock_type_parameter( + type_name, path, target=target + ) + except Exception as exc: + self.armStrip.show_error(str(exc)) + else: + if skipped: + self.typesPane.show_entries_note( + f"skipped: {', '.join(skipped)}" + ) + else: + # a clean declaration leaves no stale error or + # skipped note behind (plan task 5.6) + self.typesPane.reset_entries_note() + self.cancel_arm() + return + if self.armed_follower is None: + return + try: + self.instrument.lock(self.armed_follower, target) + except Exception as exc: + self.armStrip.show_error(str(exc)) + else: + self.cancel_arm() + + def cancel_arm(self) -> None: + """Disarm the target picker without picking anything (either kind + of pick: a Follower's Lock or a Type Lock's re-target).""" + self.armed_follower = None + self.armed_type_lock = None + self.armStrip.disarm() + + @QtCore.Slot(QtCore.QModelIndex) + def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: + """A row click while the pick is armed chooses that row's parameter + as the Target (the mock's rowClick); a submodule click does + nothing.""" + if self.armed_follower is None and self.armed_type_lock is None: + return + source_index = self.proxyModel.mapToSource(index) + source_index = source_index.sibling(source_index.row(), 0) + item = self.model.itemFromIndex(source_index) + if item is not None and item.element is not None: + self.pick_lock_target(item.name) + + # ------------------------------------------------------------------ + # the Locks panel (plan task 5.4) + # ------------------------------------------------------------------ + + @QtCore.Slot(bool) + def _on_locks_action_toggled(self, checked: bool) -> None: + """Show or hide the Locks panel with the toolbar action, and + rebuild its rows when it becomes visible (a hidden panel costs + nothing).""" + self.locksPanel.setVisible(checked) + if checked: + self.refresh_locks_panel() + + @QtCore.Slot() + def refresh_locks_panel(self) -> None: + """Rebuild the Locks panel's rows from the client-side state (plan + task 5.4): the rows from ``PMState.locks`` and ``PMState.types``, + each row's parameter resolved through the instrument. + + Runs at the end of :meth:`apply_locks` and of + :meth:`_on_type_changed` — the Type Lock rows depend on the Types — + and when the toolbar action shows the panel, but only while the + panel is shown, so a hidden panel costs nothing.""" + if self.locksPanel.isHidden(): + return + rows = build_lock_rows( + self.state.locks, self.state.types, self.instrument.name + ) + elements: Dict[str, Any] = {} + for path in _lock_row_paths(rows): + try: + elements[path] = nestedAttributeFromString(self.instrument, path) + except (AttributeError, RuntimeError) as exc: + logger.debug( + f"could not resolve the parameter of the Locks panel " + f"row {path}: {exc}" + ) + self.locksPanel.rebuild( + rows, elements, self.state.types, self.state.locks + ) + + @QtCore.Slot(str) + def _on_panel_toggle_lock(self, path: str) -> None: + """The Locks panel's lock/relock toggle: toggle the Lock of the + parameter at ``path``. A refused toggle shows the Server's error + text on the panel's note label.""" + try: + self.instrument.toggle_lock(path) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + self.locksPanel.reset_note() + + @QtCore.Slot(str) + def _on_panel_remove_lock(self, path: str) -> None: + """The Locks panel's remove button: remove the Lock of the + parameter at ``path``. A refused removal shows the Server's error + text on the panel's note label.""" + try: + self.instrument.remove_lock(path) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + self.locksPanel.reset_note() + + @QtCore.Slot(str, str, str) + def _on_panel_lock_all(self, type_name: str, entry: str, target: str) -> None: + """The Type Lock row's "lock all" button: declare the Type Lock + again with the entry's stored Target — called with ``target=None`` + the Server would re-point the rule to the Globals default (D17). + Instance parameters the declaration skips, because they carry a + Lock on another Target (D17), are named on the note label.""" + try: + skipped = self.instrument.lock_type_parameter( + type_name, entry, target=target + ) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + if skipped: + self.locksPanel.show_note(f"skipped: {', '.join(skipped)}") + else: + self.locksPanel.reset_note() + + @QtCore.Slot(str, str) + def _on_panel_remove_rule(self, type_name: str, entry: str) -> None: + """The Type Lock row's "remove rule" button: remove only the rule + (D17); the Locks it created stay until they are removed one by + one. A refused removal shows the Server's error text on the + panel's note label.""" + try: + self.instrument.unlock_type_parameter(type_name, entry) + except Exception as exc: + self.locksPanel.show_error(str(exc)) + else: + self.locksPanel.reset_note() + + @QtCore.Slot() + def _lock_selection_from_panel(self) -> None: + """The panel's "Lock selection to…": arm the target picker for the + tree's current parameter row. With no parameter row current, the + note label says so and nothing is armed; a successful arm clears a + stale error from the note (plan task 5.6).""" + item = self._getCurrentItem() + if item is None or item.element is None: + self.locksPanel.show_error("Select a parameter in the tree first.") + return + self.locksPanel.reset_note() + self.arm_lock(item.name) + + @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) + def _on_tree_current_changed( + self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex + ) -> None: + """Keep the panel's selected label on the tree's current row: a + parameter row shows its path, a submodule row or no selection shows + "no parameter selected".""" + item = self._getCurrentItem() + if item is not None and item.element is not None: + self.locksPanel.selectedLabel.setText(item.name) + else: + self.locksPanel.selectedLabel.setText("no parameter selected") + + @QtCore.Slot() + def apply_tints(self) -> None: + """Recompute every row's Type claims and repaint the tints and + gutter bands (plan task 5.2). + + Runs after the state was refreshed from the Parameter Manager (on a + model reload), on every ``pm-type-update`` Broadcast, and after a + parameter was created or removed by a Broadcast, since matching + depends on which parameters exist. The Types pane rebuilds from + the same state at the end (plan task 5.5). + """ + self.typePalette.sync(self.state.types) + claims = compute_claims(self.state.types, self._model_parameters()) + self._apply_tints_to_rows(self.model.invisibleRootItem(), claims) + self.refresh_types_pane() + + def _model_parameters(self) -> Dict[str, str]: + """Every parameter row of the source model as ``{path: unit}``.""" + parameters: Dict[str, str] = {} + self._collect_parameters(self.model.invisibleRootItem(), parameters) + return parameters + + def _collect_parameters( + self, parent: QtGui.QStandardItem, parameters: Dict[str, str] + ) -> None: + for row in range(parent.rowCount()): + item = parent.child(row, 0) + if item is None: + continue + if item.element is not None: # type: ignore[attr-defined] + # a parameter row; a submodule row's element is None + unitItem = parent.child(row, 1) + parameters[item.name] = "" if unitItem is None else unitItem.text() + if item.hasChildren(): + self._collect_parameters(item, parameters) + + def _apply_tints_to_rows( + self, parent: QtGui.QStandardItem, claims: Dict[str, Claim] + ) -> None: + """Tint every row of ``parent`` that has a Claim with the Claiming + Type's colour on all columns and store its Type stack on the gutter + item; clear the background of the rows without one.""" + for row in range(parent.rowCount()): + rowItems = [parent.child(row, col) for col in range(LOCK_COLUMN + 1)] + item = rowItems[0] + if item is None: + continue + gutterItem = rowItems[GUTTER_COLUMN] + if gutterItem is None: + gutterItem = QtGui.QStandardItem() + parent.setChild(row, GUTTER_COLUMN, gutterItem) + claim = claims.get(item.name) + colours = ( + self.typePalette.colours(claim.type) if claim is not None else None + ) + if claim is not None and colours is not None: + # claimed rows carry the Claiming Type's tint, alternating + # with tintAlt over the sibling rows so the striping survives + background = colours["tintAlt"] if item.row() % 2 else colours["tint"] + for rowItem in rowItems: + if rowItem is not None: + rowItem.setData( + background, QtCore.Qt.ItemDataRole.BackgroundRole + ) + gutterItem.setData(claim.stack[:3], GUTTER_ROLE) + else: + # a lost claim reverts the row to the default look + for rowItem in rowItems: + if rowItem is not None: + rowItem.setData(None, QtCore.Qt.ItemDataRole.BackgroundRole) + gutterItem.setData([], GUTTER_ROLE) + if item.hasChildren(): + self._apply_tints_to_rows(item, claims) + + # ------------------------------------------------------------------ + # the Types pane (plan task 5.5) + # ------------------------------------------------------------------ + + @QtCore.Slot() + def refresh_types_pane(self) -> None: + """Rebuild the Types pane's three panes from the client-side state + (plan task 5.5): the Types, the model's parameter rows and the + tint palette. + + Runs at the end of :meth:`apply_tints` — so a refresh, a profile + load, a ``pm-type-update`` Broadcast and a structural Broadcast + all refresh it — and after every pane action's Server call + returns (the Broadcast arrives on top of that; a double rebuild + is fine). The pane keeps the selected Type across rebuilds and + drops the selection when the Type is gone.""" + self.typesPane.rebuild( + self.state.types, self._model_parameters(), self.typePalette + ) + + @QtCore.Slot(str) + def _on_pane_type_selected(self, name: str) -> None: + """The Types pane's selected Type changed: re-render the entries + and Instances panes for it.""" + self.typesPane.refresh_selected_panes( + self.state.types, self._model_parameters(), self.typePalette + ) + + @QtCore.Slot(str) + def _on_pane_add_type(self, name: str) -> None: + """The New type strip: create the Type. A refused creation shows + the Server's error text on the strip's note; on success the new + Type is selected once the pane rebuilds (the ``pm-type-update`` + Broadcast brings it into the state).""" + try: + self.instrument.add_type(name) + except Exception as exc: + self.typesPane.show_type_error(str(exc)) + else: + self.typesPane.reset_type_note() + self.typesPane.newTypeEdit.clear() + self.typesPane.select_type(name) + self.refresh_types_pane() + + @QtCore.Slot(str, str, str, str) + def _on_pane_add_entry( + self, type_name: str, path: str, default_text: str, unit: str + ) -> None: + """The "Add to type" strip: add the entry with its parsed default + (``None`` when the text is empty) and unit (D11, D13). A refused + edit shows the Server's error text on the entries pane's note.""" + try: + self.instrument.add_type_parameter( + type_name, path, default=parse_default_text(default_text), unit=unit + ) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.typesPane.entryNameEdit.clear() + self.typesPane.entryDefaultEdit.clear() + self.typesPane.entryUnitEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_remove_entry(self, type_name: str, path: str) -> None: + """An own entry's Remove button: remove the entry from the Type + only (D13) — the Instances keep the parameter. A refused removal + shows the Server's error text on the entries pane's note.""" + try: + self.instrument.remove_type_parameter(type_name, path) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str, str) + def _on_pane_set_default(self, type_name: str, path: str, text: str) -> None: + """An own entry's committed default editor (Return or the set + button): set the entry's default to the parsed text (D13). A + refused set shows the Server's error text on the entries pane's + note.""" + try: + self.instrument.set_type_parameter_default( + type_name, path, parse_default_text(text) + ) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_toggle_type_lock(self, type_name: str, path: str) -> None: + """An entry's Type Lock toggle: declare the Type Lock on the + default Globals Target while the entry has no Target, remove only + the rule while it has one (D17). A refused toggle shows the + Server's error text on the entries pane's note; the Instance + parameters a declaration skips (D17) are named on it.""" + blueprint = self.state.types.get(type_name) + target = None + if blueprint is not None: + target = blueprint.parameters.get(path, {}).get("target") + try: + if target is None: + skipped = self.instrument.lock_type_parameter(type_name, path) + else: + self.instrument.unlock_type_parameter(type_name, path) + skipped = [] + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + if skipped: + self.typesPane.show_entries_note(f"skipped: {', '.join(skipped)}") + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str, str) + def _on_pane_add_nested( + self, type_name: str, submodule: str, nested: str + ) -> None: + """The "Nested type" strip: require the Nested Type ``nested`` at + the submodule (D11, D13). A refused edit shows the Server's error + text on the entries pane's note.""" + try: + self.instrument.add_nested_type(type_name, submodule, nested) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.typesPane.nestedAtEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_remove_nested(self, type_name: str, submodule: str) -> None: + """An own Nested Type's Remove button: remove the requirement + (D13) — the Instances keep the parameters. A refused removal + shows the Server's error text on the entries pane's note.""" + try: + self.instrument.remove_nested_type(type_name, submodule) + except Exception as exc: + self.typesPane.show_entries_error(str(exc)) + else: + self.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_add_instance(self, type_name: str, name: str) -> None: + """The New instance strip: create the Instance ``name`` of the + Type (D14). A refused creation shows the Server's error text on + the instances pane's note.""" + try: + self.instrument.add_instance(type_name, name) + except Exception as exc: + self.typesPane.show_instances_error(str(exc)) + else: + self.typesPane.reset_instances_note() + self.typesPane.newInstanceEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_show_instance(self, type_name: str, instance: str) -> None: + """An instance row's Show button: switch to the Parameters tab, + clear the filter, expand the tree and select the Instance's first + parameter row — the first effective entry under it, the submodule + row as the fallback — scrolled into view.""" + self.tabs.setCurrentIndex(0) + self.lineEdit.clear() + self.view.expandAll() + blueprint = self.state.types.get(type_name) + candidates = [instance] + if blueprint is not None and blueprint.effective: + first = sorted(blueprint.effective, key=lambda entry: entry.split("."))[0] + candidates.insert(0, f"{instance}.{first}") + for path in candidates: + matches = self.model.findItems( + path, + cast( + "QtCore.Qt.MatchFlags", + QtCore.Qt.MatchFlag.MatchExactly + | QtCore.Qt.MatchFlag.MatchRecursive, + ), + 0, + ) + if not matches: + continue + proxy_index = self.proxyModel.mapFromSource( + self.model.indexFromItem(matches[0]) + ) + if proxy_index.isValid(): + self.view.setCurrentIndex(proxy_index) + self.view.scrollTo(proxy_index) + break + + @QtCore.Slot() + def loadFromFile(self, loadFile: Optional[str] = None) -> None: + try: + self.instrument.fromFile(filePath=loadFile, deleteMissing=False) + self.refreshAll() + + except Exception as e: + logger.info(f"Loading failed. {type(e)}: {e.args}") + + @QtCore.Slot() + def saveToFile(self) -> None: + try: + self.instrument.toFile() + except Exception as e: + logger.info(f"Saving failed. {type(e)}: {e.args}") diff --git a/test/docs_verification/technical_guide/verify_broadcasts.py b/test/docs_verification/technical_guide/verify_broadcasts.py index 22a44ee..f40ada1 100644 --- a/test/docs_verification/technical_guide/verify_broadcasts.py +++ b/test/docs_verification/technical_guide/verify_broadcasts.py @@ -56,7 +56,7 @@ ) from instrumentserver.client.proxy import SubClient from instrumentserver.config import loadConfig -from instrumentserver.gui.instruments import PMState +from instrumentserver.gui.parameter_manager import PMState from instrumentserver.params import ParameterManager PM_CLASS = "instrumentserver.params.ParameterManager" diff --git a/test/notebooks/Prototype the ParamManager.ipynb b/test/notebooks/Prototype the ParamManager.ipynb index ff1b4c3..87a2e21 100644 --- a/test/notebooks/Prototype the ParamManager.ipynb +++ b/test/notebooks/Prototype the ParamManager.ipynb @@ -34,7 +34,7 @@ "from qcodes.utils import validators\n", "\n", "from instrumentserver.gui import widgetDialog\n", - "from instrumentserver.gui.instruments import ParameterManagerGui\n", + "from instrumentserver.gui.parameter_manager import ParameterManagerGui\n", "from instrumentserver.params import ParameterManager\n", "from instrumentserver.serialize import (\n", " toDataFrame,\n", diff --git a/test/prototyping/testing_parameter_manager.py b/test/prototyping/testing_parameter_manager.py index 9161dc9..a3b429f 100755 --- a/test/prototyping/testing_parameter_manager.py +++ b/test/prototyping/testing_parameter_manager.py @@ -6,7 +6,7 @@ from instrumentserver.client import Client, ProxyInstrument from instrumentserver.gui import widgetDialog -from instrumentserver.gui.instruments import ParameterManagerGui +from instrumentserver.gui.parameter_manager import ParameterManagerGui from instrumentserver.params import ParameterManager # %% run the PM locally diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 1f62f70..b328a7f 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -42,6 +42,7 @@ disabled and the pane labels carry no trailing space. """ +import importlib import os import pytest @@ -51,13 +52,16 @@ from instrumentserver.blueprints import ( PARAMETER_CALL, PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, ParameterBroadcastBluePrint, PMLockBluePrint, PMTypeBluePrint, ) from instrumentserver.client.proxy import Client from instrumentserver.gui.base_instrument import InstrumentSortFilterProxyModel -from instrumentserver.gui.instruments import ( +from instrumentserver.gui.instruments import ItemParameters, ModelParameters +from instrumentserver.gui.parameter_manager import ( GUTTER_COLUMN, GUTTER_ROLE, GUTTER_WIDTH, @@ -68,9 +72,8 @@ TINT_COLOURS, Claim, GutterDelegate, - ItemParameters, LockArmStrip, - ModelParameters, + ModelParameterManager, ParameterManagerGui, ParameterManagerTreeView, PMState, @@ -283,6 +286,57 @@ def test_on_item_new_value_uses_the_parameter_widget_set_method(qtbot): assert widget.set_via_set_method == [42] +def _pm_broadcasts(instrument_name): + """A ``pm-lock-update`` and a ``pm-type-update`` Broadcast about + ``instrument_name``, both reporting a removal.""" + return [ + ParameterBroadcastBluePrint( + name=f"{instrument_name}.q01.IF", action=PM_LOCK_UPDATE, value=None + ), + ParameterBroadcastBluePrint( + name=f"{instrument_name}.qubit", action=PM_TYPE_UPDATE, value=None + ), + ] + + +def test_generic_model_ignores_the_parameter_manager_broadcasts(qtbot): + """The generic ``ModelParameters`` has no Lock or Type signals: an + instrument window opened on a Parameter Manager without the + Parameter Manager GUI receives ``pm-lock-update`` and + ``pm-type-update`` Broadcasts and leaves its rows alone.""" + stub_instrument = InstrumentBase("pm_generic_stub") + model = ModelParameters(stub_instrument, "parameters", ItemParameters) + try: + assert not hasattr(model, "lockChanged") + assert not hasattr(model, "typeChanged") + rows_before = model.rowCount() + for bp in _pm_broadcasts(stub_instrument.name): + model.updateParameter(bp) + assert model.rowCount() == rows_before + finally: + model.stopListener() + + +def test_parameter_manager_model_routes_the_parameter_manager_broadcasts(qtbot): + """``ModelParameterManager`` emits ``lockChanged`` with the Follower's + path and ``typeChanged`` with the Type's name, both relative to the + instrument, and adds no row for either.""" + stub_instrument = InstrumentBase("pm_model_stub") + model = ModelParameterManager(stub_instrument, "parameters", ItemParameters) + locks, types = [], [] + model.lockChanged.connect(lambda path, lock: locks.append((path, lock))) + model.typeChanged.connect(lambda name, bp: types.append((name, bp))) + try: + rows_before = model.rowCount() + for bp in _pm_broadcasts(stub_instrument.name): + model.updateParameter(bp) + assert locks == [("q01.IF", None)] + assert types == [("qubit", None)] + assert model.rowCount() == rows_before + finally: + model.stopListener() + + def test_state_on_construction_holds_types_and_locks_created_before( qtbot, pm, server_port ): @@ -533,6 +587,19 @@ def _type_blueprint(name, entries, nested=None, registry=None, defaults=None, ta ) +def test_old_gui_instruments_path_still_serves_the_moved_names(): + """Station configs name the widget by its dotted path, which the Server + resolves with ``import_module`` and ``getattr``. The path from before + the Parameter Manager GUI moved to ``gui.parameter_manager`` keeps + resolving to the same objects; other names still fail.""" + old = importlib.import_module("instrumentserver.gui.instruments") + new = importlib.import_module("instrumentserver.gui.parameter_manager") + assert getattr(old, "ParameterManagerGui") is new.ParameterManagerGui + assert getattr(old, "PMState") is new.PMState + with pytest.raises(AttributeError): + getattr(old, "TypesPane") + + def test_compute_claims_requires_every_path_with_the_declared_unit(): """A submodule is an Instance only when it carries every effective path of the Type with the unit the Type declares (D12).""" diff --git a/test/test_async_requests/serverConfig.yml b/test/test_async_requests/serverConfig.yml index baa4050..d12c497 100644 --- a/test/test_async_requests/serverConfig.yml +++ b/test/test_async_requests/serverConfig.yml @@ -11,7 +11,7 @@ instruments: gui: # By having the parameter manager GUI as the gui type, we can have it directly in the server instead of on a separate window. - type: instrumentserver.gui.instruments.ParameterManagerGui + type: instrumentserver.gui.parameter_manager.ParameterManagerGui test1: type: 'instrumentserver.testing.dummy_instruments.generic.DummyInstrumentTimeout' From b1834d0e57c76065ece1263dc4ab1815ea8013fa Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 14:48:49 -0500 Subject: [PATCH 097/107] Add column and editor hooks to the generic parameter GUI ModelParameters now asks headerLabels() and rowItems() for its columns and row cells instead of pinning three columns after loading. The Parameter Manager model extends both, so its copied insertItemTo, the post-load column widening, the _ensure_extra_items walk and the two fallbacks in the tint and Lock passes that created missing cells are gone. ParameterDelegate.createEditor builds the editor through makeParameterWidget(); the Parameter Manager's delete delegate overrides only that instead of copying createEditor. ParametersTreeView takes its delegate from delegateClass and calls setupColumns() before opening the persistent editors; ParameterManagerTreeView now derives from it and drops its copy of onItemNewValue. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/instrumentserver/gui/instruments.py | 60 +++++-- .../gui/parameter_manager/widget.py | 149 +++++------------- 2 files changed, 84 insertions(+), 125 deletions(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 90238e1..073b437 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -5,6 +5,7 @@ Any, Callable, Dict, + List, Optional, Type, Union, @@ -174,9 +175,7 @@ def createEditor( # type: ignore[override] if not item.showDelegate: # type: ignore[attr-defined] return None # type: ignore[return-value] - element = item.element # type: ignore[attr-defined] - - ret = ParameterWidget(element, widget) + ret = self.makeParameterWidget(item, widget) self.parameters[item.name] = ret # type: ignore[attr-defined] ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] @@ -198,6 +197,15 @@ def createEditor( # type: ignore[override] # logger.warning(f"Failed to get value for parameter {element.name}: {e}") return ret + def makeParameterWidget( + self, item: QtGui.QStandardItem, parent: QtWidgets.QWidget + ) -> ParameterWidget: + """The editor widget for the parameter of ``item``. A delegate that + adds buttons next to the value overrides this; + :meth:`createEditor` does the rest (registering the widget and + installing the navigation filter).""" + return ParameterWidget(item.element, parent) # type: ignore[attr-defined] + class ValueCellNavigationFilter(QtCore.QObject): """Event filter installed on value input widgets to handle Escape, Tab, and Shift+Tab.""" @@ -282,8 +290,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: } super().__init__(*args, **kwargs) - self.setColumnCount(3) - self.setHorizontalHeaderLabels([self.attr, "unit", ""]) + labels = self.headerLabels() + self.setColumnCount(len(labels)) + self.setHorizontalHeaderLabels(labels) # Live updates items self.cliThread = QtCore.QThread() @@ -365,29 +374,40 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: # The model can't actually modify the widget since it knows nothing about the view itself. self.itemNewValue.emit(item[0].name, bp.value) + def headerLabels(self) -> List[str]: + """The header label of every column: name, unit and delegate. + A model with more columns extends this list and :meth:`rowItems` + together.""" + return [self.attr, "unit", ""] + + def rowItems(self, item: QtGui.QStandardItem) -> List[QtGui.QStandardItem]: + """The items of one row, ``item`` first, one per column of + :meth:`headerLabels`. :meth:`insertItemTo` inserts them.""" + # A parameter might not have a unit + unit = "" + if item.element is not None: # type: ignore[attr-defined] + unit = item.element.unit # type: ignore[attr-defined] + return [item, QtGui.QStandardItem(unit), QtGui.QStandardItem()] + def insertItemTo( self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem ) -> None: if item is not None: - # A parameter might not have a unit - unit = "" - if item.element is not None: # type: ignore[attr-defined] - unit = item.element.unit # type: ignore[attr-defined] - unitItem = QtGui.QStandardItem(unit) - extraItem = QtGui.QStandardItem() - + row = self.rowItems(item) if parent == self: rowCount = self.rowCount() - self.setItem(rowCount, 0, item) - self.setItem(rowCount, 1, unitItem) - self.setItem(rowCount, 2, extraItem) + for column, cell in enumerate(row): + self.setItem(rowCount, column, cell) else: - parent.appendRow([item, unitItem, extraItem]) + parent.appendRow(row) self.newItem.emit(item) class ParametersTreeView(InstrumentTreeViewBase): + #: The delegate class of the value column (column 2). + delegateClass: type[ParameterDelegate] = ParameterDelegate + def __init__( self, model: QtCore.QAbstractItemModel, @@ -396,12 +416,18 @@ def __init__( ) -> None: super().__init__(model, [2], *args, **kwargs) - self.delegate = ParameterDelegate(self) + self.delegate = self.delegateClass(self) self.delegate.navFilter = ValueCellNavigationFilter(self) self.setItemDelegateForColumn(2, self.delegate) + self.setupColumns() self.setAllDelegatesPersistent() + def setupColumns(self) -> None: + """Set up the delegates and header of any columns beyond name, + unit and value. Runs before the persistent editors are opened; + the default does nothing.""" + @QtCore.Slot(object, object) def onItemNewValue(self, itemName: str, value: Any) -> None: widget = self.delegate.parameters[itemName] diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py index 3c64d82..127ed3f 100644 --- a/src/instrumentserver/gui/parameter_manager/widget.py +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -5,6 +5,7 @@ from typing import ( Any, Dict, + List, Optional, Tuple, Union, @@ -32,14 +33,13 @@ paramTypeFromName, ) from .. import keepSmallHorizontally -from ..base_instrument import InstrumentTreeViewBase from ..instruments import ( InstrumentParameters, ModelParameters, ParameterDelegate, - ValueCellNavigationFilter, + ParametersTreeView, ) -from ..parameters import AnyInput, ParameterWidget +from ..parameters import ParameterWidget from .logic import ( GUTTER_COLUMN, GUTTER_ROLE, @@ -257,50 +257,16 @@ class ModelParameterManager(ModelParameters): #: when the Type was removed). No model item is touched for this action. typeChanged = QtCore.Signal(str, object) - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - # ModelParameters pins the column count at 3 after loading; widen it - # again and give every loaded row the gutter item the narrow count - # dropped, and the Lock column item (plan task 5.3) - self.setColumnCount(LOCK_COLUMN + 1) - self.setHorizontalHeaderLabels([self.attr, "unit", "", "", "locked to"]) - self._ensure_extra_items(self.invisibleRootItem()) - - def _ensure_extra_items(self, parent: QtGui.QStandardItem) -> None: - """Give every row under ``parent`` its gutter item and its Lock - column item.""" - for row in range(parent.rowCount()): - for column in (GUTTER_COLUMN, LOCK_COLUMN): - if parent.child(row, column) is None: - parent.setChild(row, column, QtGui.QStandardItem()) - item = parent.child(row, 0) - if item is not None and item.hasChildren(): - self._ensure_extra_items(item) - - def insertItemTo( - self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem - ) -> None: - if item is not None: - # A parameter might not have a unit - unit = "" - if item.element is not None: # type: ignore[attr-defined] - unit = item.element.unit # type: ignore[attr-defined] - unitItem = QtGui.QStandardItem(unit) - extraItem = QtGui.QStandardItem() - gutterItem = QtGui.QStandardItem() - lockItem = QtGui.QStandardItem() - - if parent == self: - rowCount = self.rowCount() - self.setItem(rowCount, 0, item) - self.setItem(rowCount, 1, unitItem) - self.setItem(rowCount, 2, extraItem) - self.setItem(rowCount, GUTTER_COLUMN, gutterItem) - self.setItem(rowCount, LOCK_COLUMN, lockItem) - else: - parent.appendRow([item, unitItem, extraItem, gutterItem, lockItem]) + def headerLabels(self) -> List[str]: + # the gutter column (plan task 5.2) and the Lock column (plan + # task 5.3) follow the name, unit and delegate columns + return super().headerLabels() + ["", "locked to"] - self.newItem.emit(item) + def rowItems(self, item: QtGui.QStandardItem) -> List[QtGui.QStandardItem]: + return super().rowItems(item) + [ + QtGui.QStandardItem(), + QtGui.QStandardItem(), + ] def _has_row(self, full_name: str) -> bool: """Whether the model holds a row for the dotted path ``full_name`` @@ -350,38 +316,20 @@ class ParameterDeleteDelegate(ParameterDelegate): #: the Parameter Manager GUI toggles that parameter's Lock. toggleLock = QtCore.Signal(str) - def createEditor( # type: ignore[override] - self, - widget: QtWidgets.QWidget, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, - ) -> QtWidgets.QWidget: - item = self.getItem(index) - - if not item.showDelegate: # type: ignore[attr-defined] - return None # type: ignore[return-value] - - element = item.element # type: ignore[attr-defined] - rw = self.makeRemoveWidget(item.name, widget) # type: ignore[attr-defined] - lw = self.make_lock_widget(item.name, widget) + def makeParameterWidget( + self, item: QtGui.QStandardItem, parent: QtWidgets.QWidget + ) -> ParameterWidget: + rw = self.makeRemoveWidget(item.name, parent) # type: ignore[attr-defined] + lw = self.make_lock_widget(item.name, parent) # type: ignore[attr-defined] ret = ParameterWidget( - parameter=element, parent=widget, additionalWidgets=[lw, rw] + parameter=item.element, # type: ignore[attr-defined] + parent=parent, + additionalWidgets=[lw, rw], ) # the lock button is kept on the row's ParameterWidget so the # Parameter Manager GUI can restyle it with the Lock state ret.lockButton = lw - self.parameters[item.name] = ret # type: ignore[attr-defined] - ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] - - if self.navFilter is not None: - if isinstance(ret.paramWidget, AnyInput): - input_widget = ret.paramWidget.input - else: - input_widget = ret.paramWidget - input_widget.installEventFilter(self.navFilter) - self.navFilter.registerWidget(input_widget, index) - return ret def make_lock_widget( @@ -412,7 +360,7 @@ def makeRemoveWidget( # TODO: Make sure that the refresh button refreshes the profiles as well as the model -class ParameterManagerTreeView(InstrumentTreeViewBase): +class ParameterManagerTreeView(ParametersTreeView): #: Signal(str) #: Emitted when the user picks "Lock to…" in the context menu; the #: Parameter Manager GUI arms the target picker for that parameter. @@ -422,19 +370,30 @@ class ParameterManagerTreeView(InstrumentTreeViewBase): #: Emitted when the user picks "Unlock" in the context menu. unlockRequested = QtCore.Signal(str) + delegateClass = ParameterDeleteDelegate + delegate: ParameterDeleteDelegate + def __init__( self, model: QtCore.QAbstractItemModel, *args: Any, **kwargs: Any, ) -> None: - super().__init__(model, [2], *args, **kwargs) - - self.delegate = ParameterDeleteDelegate(self) - self.delegate.navFilter = ValueCellNavigationFilter(self) + super().__init__(model, *args, **kwargs) - self.setItemDelegateForColumn(2, self.delegate) + # the lock actions act on the row the context menu was opened for + # (self.lastSelectedItem, set by the base onContextMenuRequested + # before the menu opens); the Parameter Manager GUI enables and + # disables them in its aboutToShow slot + self.lockToAction = QtWidgets.QAction("Lock to…") + self.lockToAction.triggered.connect(self._on_lock_to_action_trigger) + self.unlockAction = QtWidgets.QAction("Unlock") + self.unlockAction.triggered.connect(self._on_unlock_action_trigger) + self.contextMenu.addSeparator() + self.contextMenu.addAction(self.lockToAction) + self.contextMenu.addAction(self.unlockAction) + def setupColumns(self) -> None: # the gutter column exists only in the Parameter Manager's own model # (ModelParameterManager) self.gutterDelegate = GutterDelegate(self) @@ -461,19 +420,6 @@ def __init__( ) header.resizeSection(LOCK_COLUMN, LOCK_COLUMN_WIDTH) self.setTreePosition(0) - self.setAllDelegatesPersistent() - - # the lock actions act on the row the context menu was opened for - # (self.lastSelectedItem, set by the base onContextMenuRequested - # before the menu opens); the Parameter Manager GUI enables and - # disables them in its aboutToShow slot - self.lockToAction = QtWidgets.QAction("Lock to…") - self.lockToAction.triggered.connect(self._on_lock_to_action_trigger) - self.unlockAction = QtWidgets.QAction("Unlock") - self.unlockAction.triggered.connect(self._on_unlock_action_trigger) - self.contextMenu.addSeparator() - self.contextMenu.addAction(self.lockToAction) - self.contextMenu.addAction(self.unlockAction) @QtCore.Slot() def _on_lock_to_action_trigger(self) -> None: @@ -491,17 +437,6 @@ def _on_unlock_action_trigger(self) -> None: if item is not None and item.element is not None: self.unlockRequested.emit(item.name) - @QtCore.Slot(object, object) - def onItemNewValue(self, itemName: str, value: Any) -> None: - widget = self.delegate.parameters[itemName] - try: - # use the abstract set method defined in parameter widget so it works for different types of widgets - widget._setMethod(value) - except RuntimeError: - logger.debug( - f"Could not set value for {itemName} to {value}. Object is not being shown right now." - ) - class ProfilesManager(QtWidgets.QComboBox): #: Signal() @@ -918,9 +853,8 @@ def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: if item is None: continue lockItem = parent.child(row, LOCK_COLUMN) - if lockItem is None: - lockItem = QtGui.QStandardItem() - parent.setChild(row, LOCK_COLUMN, lockItem) + # ModelParameterManager.rowItems gives every row this item + assert lockItem is not None if item.element is None: # a submodule row carries no Lock state of its own lockItem.setText("") @@ -1283,9 +1217,8 @@ def _apply_tints_to_rows( if item is None: continue gutterItem = rowItems[GUTTER_COLUMN] - if gutterItem is None: - gutterItem = QtGui.QStandardItem() - parent.setChild(row, GUTTER_COLUMN, gutterItem) + # ModelParameterManager.rowItems gives every row this item + assert gutterItem is not None claim = claims.get(item.name) colours = ( self.typePalette.colours(claim.type) if claim is not None else None From 50a074e58dfef8aa544ec606ff360540e314b440 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 15:08:56 -0500 Subject: [PATCH 098/107] Simplify the Parameter Manager model's row tracking and tidy leftovers ModelParameterManager no longer scans the tree before and after every value Broadcast to tell whether a row was added: it listens to its own newItem while a Broadcast is handled and emits structureChanged once when rows were added or a parameter was removed. Leftovers from the split: - lock_row_paths is public; widget.py no longer imports a private name. - LockableParameterWidget carries the row's lock button as a declared attribute instead of one set on ParameterWidget from outside. - ParameterManagerTreeView.setupColumns drops its column-count checks; the view is always built over a ModelParameterManager (the one test that used a plain ModelParameters now uses the PM model). - The package exports only ParameterManagerGui, PMState and ModelParameterManager; the tests import the rest from the submodules. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../gui/parameter_manager/__init__.py | 54 +------ .../gui/parameter_manager/logic.py | 4 +- .../gui/parameter_manager/widget.py | 136 ++++++++++-------- test/pytest/test_pm_gui.py | 22 +-- 4 files changed, 92 insertions(+), 124 deletions(-) diff --git a/src/instrumentserver/gui/parameter_manager/__init__.py b/src/instrumentserver/gui/parameter_manager/__init__.py index 5cba22e..55aa4d9 100644 --- a/src/instrumentserver/gui/parameter_manager/__init__.py +++ b/src/instrumentserver/gui/parameter_manager/__init__.py @@ -12,55 +12,5 @@ ``instrumentserver.gui.instruments.ParameterManagerGui`` still works. """ -from .logic import ( # noqa: F401 - GUTTER_COLUMN, - GUTTER_ROLE, - GUTTER_WIDTH, - LOCK_COLOUR, - LOCK_COLUMN, - LOCK_COLUMN_WIDTH, - TINT_COLOURS, - TINT_PALETTE, - Claim, - EntryRow, - LockRow, - PMState, - TypePalette, - also_types, - build_lock_rows, - compute_claims, - followers_reaching, - instances_of_type, - lock_button_tooltip, - lock_column_text, - lock_root, - parse_default_text, - rank_lock_targets, - relative_path, - type_entry_rows, -) -from .panels import ( # noqa: F401 - ENTRIES_DEFAULT_WIDTH, - ENTRIES_LOCK_WIDTH, - ENTRIES_UNIT_WIDTH, - INSTANCES_ALSO_WIDTH, - INSTANCES_BUTTON_WIDTH, - INSTANCES_COUNT_WIDTH, - LOCK_PANEL_BUTTONS_WIDTH, - LOCK_PANEL_NOTE, - LOCK_PANEL_VALUE_WIDTH, - LOCK_ROW_ROLE, - GutterDelegate, - LockArmStrip, - LocksPanel, - TypesPane, - make_lock_button, -) -from .widget import ( # noqa: F401 - AddParameterWidget, - ModelParameterManager, - ParameterDeleteDelegate, - ParameterManagerGui, - ParameterManagerTreeView, - ProfilesManager, -) +from .logic import PMState # noqa: F401 +from .widget import ModelParameterManager, ParameterManagerGui # noqa: F401 diff --git a/src/instrumentserver/gui/parameter_manager/logic.py b/src/instrumentserver/gui/parameter_manager/logic.py index ea4101b..2633a91 100644 --- a/src/instrumentserver/gui/parameter_manager/logic.py +++ b/src/instrumentserver/gui/parameter_manager/logic.py @@ -548,12 +548,12 @@ def walk(path: str) -> Optional[LockRow]: return rows -def _lock_row_paths(rows: List[LockRow]) -> List[str]: +def lock_row_paths(rows: List[LockRow]) -> List[str]: """Every row path of the built rows, depth first.""" paths: List[str] = [] for row in rows: paths.append(row.path) - paths.extend(_lock_row_paths(row.children)) + paths.extend(lock_row_paths(row.children)) return paths diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py index 127ed3f..c3f26ab 100644 --- a/src/instrumentserver/gui/parameter_manager/widget.py +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -14,10 +14,7 @@ from ... import QtCore, QtGui, QtWidgets from ...blueprints import ( - PARAMETER_CALL, - PARAMETER_CREATION, PARAMETER_DELETION, - PARAMETER_UPDATE, PM_LOCK_UPDATE, PM_TYPE_UPDATE, ParameterBroadcastBluePrint, @@ -49,12 +46,12 @@ Claim, PMState, TypePalette, - _lock_row_paths, build_lock_rows, compute_claims, followers_reaching, lock_button_tooltip, lock_column_text, + lock_row_paths, parse_default_text, rank_lock_targets, relative_path, @@ -268,20 +265,17 @@ def rowItems(self, item: QtGui.QStandardItem) -> List[QtGui.QStandardItem]: QtGui.QStandardItem(), ] - def _has_row(self, full_name: str) -> bool: - """Whether the model holds a row for the dotted path ``full_name`` - (the Broadcast name with the instrument name stripped).""" - return bool( - self.findItems( - full_name, - cast( - "QtCore.Qt.MatchFlags", - QtCore.Qt.MatchFlag.MatchExactly - | QtCore.Qt.MatchFlag.MatchRecursive, - ), - 0, - ) - ) + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # set by newItem while a Broadcast is handled, so updateParameter + # knows the Broadcast added a row; rows added while the model loads + # or reloads never reach updateParameter's check + self._rows_added = False + self.newItem.connect(self._on_new_item) + + @QtCore.Slot(object) + def _on_new_item(self, item: QtGui.QStandardItem) -> None: + self._rows_added = True def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: fullName = ".".join(bp.name.split(".")[1:]) @@ -293,19 +287,39 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: if bp.action == PM_TYPE_UPDATE: self.typeChanged.emit(fullName, bp.value) return - value_update = bp.action in (PARAMETER_UPDATE, PARAMETER_CALL) - known_row = value_update and self._has_row(fullName) + self._rows_added = False super().updateParameter(bp) - # a parameter-update or parameter-call for a row the model did not - # know adds one through the base update branch; matching depends - # on which parameters exist, so the tints and gutter bands must be - # recomputed for it too (plan task 5.6), or the new row would + # a creation, or a parameter-update or parameter-call for a row the + # model did not know, adds rows through the base branches; matching + # depends on which parameters exist, so the tints and gutter bands + # must be recomputed then (plan task 5.6), or the new row would # stay untinted until the next recompute - added_row = value_update and not known_row and self._has_row(fullName) - if bp.action in (PARAMETER_CREATION, PARAMETER_DELETION) or added_row: + if self._rows_added or bp.action == PARAMETER_DELETION: self.structureChanged.emit() +class LockableParameterWidget(ParameterWidget): + """The value widget of a Parameter Manager row: a + :class:`~instrumentserver.gui.parameters.ParameterWidget` with the + row's lock button and delete button after the value. It keeps the lock + button so the Parameter Manager GUI can restyle it with the Lock + state.""" + + def __init__( + self, + parameter: Any, + lockButton: QtWidgets.QPushButton, + removeButton: QtWidgets.QPushButton, + parent: Optional[QtWidgets.QWidget] = None, + ) -> None: + super().__init__( + parameter=parameter, + parent=parent, + additionalWidgets=[lockButton, removeButton], + ) + self.lockButton = lockButton + + class ParameterDeleteDelegate(ParameterDelegate): #: Signal(str) #: Emits the name of the parameter to be deleted when the user presses the delete button. @@ -322,15 +336,12 @@ def makeParameterWidget( rw = self.makeRemoveWidget(item.name, parent) # type: ignore[attr-defined] lw = self.make_lock_widget(item.name, parent) # type: ignore[attr-defined] - ret = ParameterWidget( - parameter=item.element, # type: ignore[attr-defined] + return LockableParameterWidget( + item.element, # type: ignore[attr-defined] + lockButton=lw, + removeButton=rw, parent=parent, - additionalWidgets=[lw, rw], ) - # the lock button is kept on the row's ParameterWidget so the - # Parameter Manager GUI can restyle it with the Lock state - ret.lockButton = lw - return ret def make_lock_widget( self, fullName: str, widget: QtWidgets.QWidget @@ -394,31 +405,28 @@ def __init__( self.contextMenu.addAction(self.unlockAction) def setupColumns(self) -> None: - # the gutter column exists only in the Parameter Manager's own model - # (ModelParameterManager) + # the view is built over a ModelParameterManager, whose rows carry + # the gutter and Lock columns self.gutterDelegate = GutterDelegate(self) - if self.model().columnCount() > GUTTER_COLUMN: - self.setItemDelegateForColumn(GUTTER_COLUMN, self.gutterDelegate) - header = self.header() - # the gutter moves to visual position 0 with a fixed width; the - # tree branches stay on the name column - header.moveSection(GUTTER_COLUMN, 0) - if header.minimumSectionSize() > GUTTER_WIDTH: - header.setMinimumSectionSize(GUTTER_WIDTH) - header.setSectionResizeMode( - GUTTER_COLUMN, QtWidgets.QHeaderView.ResizeMode.Fixed - ) - header.resizeSection(GUTTER_COLUMN, GUTTER_WIDTH) - if self.model().columnCount() > LOCK_COLUMN: - # the Lock column moves between the unit and the delegate - # column, with a resizable default width - header.moveSection( - header.visualIndex(LOCK_COLUMN), header.visualIndex(2) - ) - header.setSectionResizeMode( - LOCK_COLUMN, QtWidgets.QHeaderView.ResizeMode.Interactive - ) - header.resizeSection(LOCK_COLUMN, LOCK_COLUMN_WIDTH) + self.setItemDelegateForColumn(GUTTER_COLUMN, self.gutterDelegate) + header = self.header() + assert header is not None + # the gutter moves to visual position 0 with a fixed width; the + # tree branches stay on the name column + header.moveSection(GUTTER_COLUMN, 0) + if header.minimumSectionSize() > GUTTER_WIDTH: + header.setMinimumSectionSize(GUTTER_WIDTH) + header.setSectionResizeMode( + GUTTER_COLUMN, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + header.resizeSection(GUTTER_COLUMN, GUTTER_WIDTH) + # the Lock column moves between the unit and the delegate column, + # with a resizable default width + header.moveSection(header.visualIndex(LOCK_COLUMN), header.visualIndex(2)) + header.setSectionResizeMode( + LOCK_COLUMN, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + header.resizeSection(LOCK_COLUMN, LOCK_COLUMN_WIDTH) self.setTreePosition(0) @QtCore.Slot() @@ -876,7 +884,11 @@ def _update_row_lock_widget( """Set one row's lock button and read-only state from the Lock the state holds for ``path``. A row without a Lock shows no button and renders its value editable.""" - button = getattr(widget, "lockButton", None) + button = ( + widget.lockButton + if isinstance(widget, LockableParameterWidget) + else None + ) lock = self.state.locks.get(path) if lock is None: if button is not None: @@ -889,8 +901,10 @@ def _update_row_lock_widget( button.setToolTip(tooltip) button.setProperty("locked", lock.locked) # re-polish so the locked property restyles the button - button.style().unpolish(button) - button.style().polish(button) + style = button.style() + if style is not None: + style.unpolish(button) + style.polish(button) button.setVisible(True) widget.set_read_only(lock.locked) @@ -1075,7 +1089,7 @@ def refresh_locks_panel(self) -> None: self.state.locks, self.state.types, self.instrument.name ) elements: Dict[str, Any] = {} - for path in _lock_row_paths(rows): + for path in lock_row_paths(rows): try: elements[path] = nestedAttributeFromString(self.instrument, path) except (AttributeError, RuntimeError) as exc: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index b328a7f..9bff146 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -61,21 +61,14 @@ from instrumentserver.client.proxy import Client from instrumentserver.gui.base_instrument import InstrumentSortFilterProxyModel from instrumentserver.gui.instruments import ItemParameters, ModelParameters -from instrumentserver.gui.parameter_manager import ( +from instrumentserver.gui.parameter_manager.logic import ( GUTTER_COLUMN, GUTTER_ROLE, GUTTER_WIDTH, LOCK_COLUMN, LOCK_COLUMN_WIDTH, - LOCK_PANEL_NOTE, - LOCK_ROW_ROLE, TINT_COLOURS, Claim, - GutterDelegate, - LockArmStrip, - ModelParameterManager, - ParameterManagerGui, - ParameterManagerTreeView, PMState, TypePalette, also_types, @@ -90,6 +83,17 @@ relative_path, type_entry_rows, ) +from instrumentserver.gui.parameter_manager.panels import ( + LOCK_PANEL_NOTE, + LOCK_ROW_ROLE, + GutterDelegate, + LockArmStrip, +) +from instrumentserver.gui.parameter_manager.widget import ( + ModelParameterManager, + ParameterManagerGui, + ParameterManagerTreeView, +) from instrumentserver.gui.parameters import ParameterWidget from instrumentserver.gui.shortcuts import KeyboardShortcutManager @@ -271,7 +275,7 @@ def test_on_item_new_value_uses_the_parameter_widget_set_method(qtbot): only the input widgets have. The stub's paramWidget deliberately has no ``setValue``, so the old code would raise AttributeError here.""" stub_instrument = InstrumentBase("pm_tree_stub") - model = ModelParameters(stub_instrument, "parameters", ItemParameters) + model = ModelParameterManager(stub_instrument, "parameters", ItemParameters) view = ParameterManagerTreeView(InstrumentSortFilterProxyModel(model)) qtbot.addWidget(view) From 7ba829c7f20b1e17ca554182c75bd72a1b063320 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 15:18:09 -0500 Subject: [PATCH 099/107] Split ParameterManagerGui into Locks and Types controllers ParameterManagerGui (about 1,000 lines) owned the tints, the Lock column and buttons, the arm strip's target pick, the Locks panel handlers, the Types pane handlers and about 50 signal connections. It now builds the widgets and hands the behaviour to two controllers that wire their own signals: - LocksController: the Lock column, lock buttons and read-only values, the lock/unlock actions and shortcuts, the arm strip (armed_follower, armed_type_lock) and the Locks panel. - TypesController: the tints and gutter bands, and the Types tab actions. The methods moved unchanged apart from reaching the GUI's widgets through self.gui; the set of signal connections is the same, and the Types controller still connects before the Locks one so a structural change repaints the tints before the Lock pass. The model's {path: unit} walk moved to ModelParameterManager.parameter_units(), which both controllers use. Tests reach the arm state and methods through gui.locksController. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../gui/parameter_manager/panels.py | 2 +- .../gui/parameter_manager/widget.py | 914 +++++++++--------- test/pytest/test_pm_gui.py | 58 +- 3 files changed, 505 insertions(+), 469 deletions(-) diff --git a/src/instrumentserver/gui/parameter_manager/panels.py b/src/instrumentserver/gui/parameter_manager/panels.py index eeb6a0a..cbccd7d 100644 --- a/src/instrumentserver/gui/parameter_manager/panels.py +++ b/src/instrumentserver/gui/parameter_manager/panels.py @@ -111,7 +111,7 @@ def make_lock_button( the purple ``locked`` fill. ``target`` is the Target relative to the Parameter Manager for the state tooltip; the tree's delegate passes ``None`` and leaves the tooltip to - :meth:`.ParameterManagerGui._update_row_lock_widget`.""" + :meth:`.LocksController._update_row_lock_widget`.""" button = QtWidgets.QPushButton( QtGui.QIcon(":/icons/lock.svg"), "", parent=parent ) diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py index c3f26ab..4f1c2b5 100644 --- a/src/instrumentserver/gui/parameter_manager/widget.py +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -277,6 +277,28 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def _on_new_item(self, item: QtGui.QStandardItem) -> None: self._rows_added = True + def parameter_units(self) -> Dict[str, str]: + """Every parameter row as ``{path: unit}``.""" + parameters: Dict[str, str] = {} + root = self.invisibleRootItem() + assert root is not None + self._collect_parameters(root, parameters) + return parameters + + def _collect_parameters( + self, parent: QtGui.QStandardItem, parameters: Dict[str, str] + ) -> None: + for row in range(parent.rowCount()): + item = parent.child(row, 0) + if item is None: + continue + if item.element is not None: # type: ignore[attr-defined] + # a parameter row; a submodule row's element is None + unitItem = parent.child(row, 1) + parameters[item.name] = "" if unitItem is None else unitItem.text() + if item.hasChildren(): + self._collect_parameters(item, parameters) + def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: fullName = ".".join(bp.name.split(".")[1:]) if bp.action == PM_LOCK_UPDATE: @@ -348,8 +370,8 @@ def make_lock_widget( ) -> QtWidgets.QPushButton: """The per-row lock button. It stays hidden until the row carries a Lock (a Lock-less row shows no button, as the mock), fills purple - while the Lock is locked, and only :meth:`ParameterManagerGui. - apply_locks` changes its state.""" + while the Lock is locked, and only + :meth:`LocksController.apply_locks` changes its state.""" w = make_lock_button(widget, locked=False) w.setVisible(False) @@ -482,307 +504,66 @@ def onCurrentIndexChanged(self, index: int) -> None: self.indexChanged.emit() -class ParameterManagerGui(InstrumentParameters): - #: Signal(str) -- - #: emitted when there's an error during parameter creation. - parameterCreationError = QtCore.Signal(str) +class LocksController(QtCore.QObject): + """The Lock behaviour of a :class:`ParameterManagerGui` (plan tasks + 5.3, 5.4 and 5.6): the tree's Lock column, lock buttons and read-only + values, the lock and unlock actions and shortcuts, the arm strip's + target pick, and the Locks panel. It works on the GUI's widgets and + state and wires them in :meth:`connectSignals`. - #: Signal() -- - #: emitted when a parameter was created successfully - parameterCreated = QtCore.Signal() + ``armed_follower`` is the Follower a pick is armed for, and + ``armed_type_lock`` the (Type, entry) pair a Type Lock re-target from + the Types tab (plan task 5.5) is armed for; both are ``None`` while + nothing is armed. + """ - def __init__( - self, - instrument: Union[ProxyInstrument, ParameterManager], - parent: Optional[QtWidgets.QWidget] = None, - **kwargs: Any, - ) -> None: - super().__init__( - instrument, - parent=None, - viewType=ParameterManagerTreeView, - callSignals=False, - modelType=ModelParameterManager, - **kwargs, - ) - # The client-side cache of the Parameter Manager's Types and Locks. - # Created before connectSignals, which wires the model's Broadcast - # routing into it. - self.state = PMState() - # The tint palette: maps each Type to its slot in TINT_PALETTE; the - # view's gutter delegate reads the colours from it. - self.typePalette = TypePalette() - self.view.gutterDelegate.typePalette = self.typePalette - self.profileManager = ProfilesManager(parent=self) - self.addParam = AddParameterWidget(parent=self) - layout = self.layout() - assert isinstance(layout, QtWidgets.QVBoxLayout) - layout.insertWidget(0, self.profileManager) - layout.addWidget(self.addParam) - # The Locks panel (plan task 5.4) sits right of the tree in a - # splitter: the view keeps its identity, so every existing layout - # consumer and test keeps working. The panel starts hidden and - # costs nothing until the toolbar action shows it. - self.locksPanel = LocksPanel(self.instrument.name, parent=self) - view_index = layout.indexOf(self.view) - layout.removeWidget(self.view) - self.locksSplitter = QtWidgets.QSplitter( - QtCore.Qt.Orientation.Horizontal, self - ) - self.locksSplitter.addWidget(self.view) - self.locksSplitter.addWidget(self.locksPanel) - self.locksSplitter.setStretchFactor(0, 3) - self.locksSplitter.setStretchFactor(1, 2) - layout.insertWidget(view_index, self.locksSplitter) - self.locksPanel.setVisible(False) - # The existing content becomes tab 0 of the tab widget; tab 1 - # holds the Types pane (plan task 5.5). - self.parametersTab = QtWidgets.QWidget(self) - self.parametersTab.setLayout(self.layout()) - self.typesTab = QtWidgets.QWidget(self) - typesTabLayout = QtWidgets.QVBoxLayout(self.typesTab) - typesTabLayout.setContentsMargins(0, 0, 0, 0) - self.typesPane = TypesPane(self.instrument.name, parent=self.typesTab) - typesTabLayout.addWidget(self.typesPane) - self.tabs = QtWidgets.QTabWidget(self) - self.tabs.addTab(self.parametersTab, "Parameters") - self.tabs.addTab(self.typesTab, "Types") - outerLayout = QtWidgets.QVBoxLayout(self) - outerLayout.setContentsMargins(0, 0, 0, 0) - outerLayout.addWidget(self.tabs) - # The arm strip sits right under the toolbar and stays hidden until - # a Lock's Target is being picked (plan task 5.3). The Follower the - # pick is armed for is kept here, and — for the Types tab's Type - # Lock re-target (plan task 5.5) — the (Type, entry) pair the - # re-target is armed for. + def __init__(self, gui: "ParameterManagerGui") -> None: + super().__init__(gui) + self.gui = gui self.armed_follower: Optional[str] = None self.armed_type_lock: Optional[Tuple[str, str]] = None - self.armStrip = LockArmStrip(self.parametersTab) - parametersLayout = self.parametersTab.layout() - assert isinstance(parametersLayout, QtWidgets.QVBoxLayout) - toolbar_index = parametersLayout.indexOf(self.toolbar) - parametersLayout.insertWidget(toolbar_index + 1, self.armStrip) - self.armStrip.setVisible(False) - # Escape over the tree cancels the pick too (harmless when the - # strip is not armed) - self.viewEscShortcut = QtWidgets.QShortcut( - QtGui.QKeySequence("Escape"), self.view - ) - self.viewEscShortcut.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) - self.viewEscShortcut.activated.connect(self.cancel_arm) - # The confirmation dialog for removing a Lock Target (plan task - # 5.6), kept on the GUI so tests can drive it; ``None`` while no - # removal that needs one is in flight. - self.removalDialog: Optional[QtWidgets.QMessageBox] = None - self.connectSignals() - self.loadProfile() def connectSignals(self) -> None: - super().connectSignals() - self.view.delegate.removeParameter.connect(self.removeParameter) - self.view.delegate.toggleLock.connect(self._toggle_lock) - self.addParam.newParamRequested.connect(self.addParameter) - self.parameterCreationError.connect(self.addParam.setError) - self.parameterCreated.connect(self.addParam.clear) - self.profileManager.indexChanged.connect(self.loadProfile) - self.model.lockChanged.connect(self._on_lock_changed) - self.model.typeChanged.connect(self._on_type_changed) - self.model.structureChanged.connect(self.apply_tints) - self.model.structureChanged.connect(self.apply_locks) - self.model.itemNewValue.connect(self._on_item_new_value) + gui = self.gui + gui.view.delegate.toggleLock.connect(self._toggle_lock) + gui.model.lockChanged.connect(self._on_lock_changed) + gui.model.structureChanged.connect(self.apply_locks) + gui.model.itemNewValue.connect(self._on_item_new_value) # the filter (and the trash toggle) hides rows; when they come # back, restoreCollapsedDict has re-opened their persistent # editors, so createEditor has built fresh ParameterWidgets whose # lock button is hidden and whose input is editable — re-apply the # Lock state to them - self.proxyModel.filterFinished.connect(self.apply_locks) - self.view.lockToRequested.connect(self.arm_lock) - self.view.unlockRequested.connect(self._unlock) - self.view.contextMenu.aboutToShow.connect(self._update_lock_actions) - self.view.clicked.connect(self._on_view_clicked) - self.armStrip.targetPicked.connect(self.pick_lock_target) - self.armStrip.cancelled.connect(self.cancel_arm) - # the Locks panel (plan task 5.4): its actions run through this GUI, - # and the tree's current row drives the panel's selected label - self.locksAction.toggled.connect(self._on_locks_action_toggled) - self.locksPanel.toggleLockRequested.connect(self._on_panel_toggle_lock) - self.locksPanel.removeLockRequested.connect(self._on_panel_remove_lock) - self.locksPanel.lockAllRequested.connect(self._on_panel_lock_all) - self.locksPanel.removeRuleRequested.connect(self._on_panel_remove_rule) - self.locksPanel.lockSelectionRequested.connect( + gui.proxyModel.filterFinished.connect(self.apply_locks) + gui.view.lockToRequested.connect(self.arm_lock) + gui.view.unlockRequested.connect(self._unlock) + gui.view.contextMenu.aboutToShow.connect(self._update_lock_actions) + gui.view.clicked.connect(self._on_view_clicked) + gui.viewEscShortcut.activated.connect(self.cancel_arm) + gui.armStrip.targetPicked.connect(self.pick_lock_target) + gui.armStrip.cancelled.connect(self.cancel_arm) + # the Locks panel (plan task 5.4): the tree's current row drives + # the panel's selected label + gui.locksAction.toggled.connect(self._on_locks_action_toggled) + gui.locksPanel.toggleLockRequested.connect(self._on_panel_toggle_lock) + gui.locksPanel.removeLockRequested.connect(self._on_panel_remove_lock) + gui.locksPanel.lockAllRequested.connect(self._on_panel_lock_all) + gui.locksPanel.removeRuleRequested.connect(self._on_panel_remove_rule) + gui.locksPanel.lockSelectionRequested.connect( self._lock_selection_from_panel ) - self.view.selectionModel().currentChanged.connect( + gui.view.selectionModel().currentChanged.connect( self._on_tree_current_changed ) - # the Types pane (plan task 5.5): its actions run through this GUI, - # and a selection change re-renders the two panes it drives - self.typesPane.typeSelected.connect(self._on_pane_type_selected) - self.typesPane.addTypeRequested.connect(self._on_pane_add_type) - self.typesPane.addEntryRequested.connect(self._on_pane_add_entry) - self.typesPane.removeEntryRequested.connect(self._on_pane_remove_entry) - self.typesPane.setDefaultRequested.connect(self._on_pane_set_default) - self.typesPane.toggleTypeLockRequested.connect( - self._on_pane_toggle_type_lock - ) - self.typesPane.retargetTypeLockRequested.connect(self.arm_type_lock) - self.typesPane.addNestedRequested.connect(self._on_pane_add_nested) - self.typesPane.removeNestedRequested.connect(self._on_pane_remove_nested) - self.typesPane.addInstanceRequested.connect(self._on_pane_add_instance) - self.typesPane.showInstanceRequested.connect(self._on_pane_show_instance) - self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) - self.shortcutManager.register("clear_add", self.addParam.clear, self) - self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) - self.shortcutManager.register("load_items", self.loadFromFile, self) - self.shortcutManager.register("save_items", self.saveToFile, self) - self.shortcutManager.register("toggle_locks", self.locksAction.toggle, self) + # the Types tab's Type Lock re-target arms the same strip + gui.typesPane.retargetTypeLockRequested.connect(self.arm_type_lock) + gui.shortcutManager.register("toggle_locks", gui.locksAction.toggle, gui) # the Lock shortcuts (plan task 5.6); the tree's two lock actions # carry their key in their tooltips - self.shortcutManager.register("lock_to", self._lock_current_item, self) - self.shortcutManager.register("unlock_item", self._unlock_current_item, self) - self.shortcutManager.register("show_types", self._toggle_tabs, self) - self.shortcutManager.register_tooltip("lock_to", self.view.lockToAction) - self.shortcutManager.register_tooltip("unlock_item", self.view.unlockAction) - - @QtCore.Slot() - def _deleteCurrentItem(self) -> None: - item = self._getCurrentItem() - if item is not None: - self.removeParameter(item.name) - - def makeToolbar(self) -> QtWidgets.QToolBar: - toolbar = super().makeToolbar() - - toolbar.addSeparator() - - loadParamAction = toolbar.addAction( - QtGui.QIcon(":/icons/load.svg"), - "Load parameters from file", - ) - loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] - self.shortcutManager.register_tooltip("load_items", loadParamAction) - - saveParamAction = toolbar.addAction( - QtGui.QIcon(":/icons/save.svg"), - "Save parameters to file", - ) - saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] - self.shortcutManager.register_tooltip("save_items", saveParamAction) - - # the Locks panel toggle (plan task 5.4); the toggled connection - # and the shortcut are wired in connectSignals, where the panel - # exists - self.locksAction = toolbar.addAction( - QtGui.QIcon(":/icons/lock.svg"), - "Show the Locks panel", - ) - self.locksAction.setCheckable(True) - self.shortcutManager.register_tooltip("toggle_locks", self.locksAction) - - return toolbar - - def refreshAll(self) -> None: - super().refreshAll() - self.instrument.refresh_profiles() - self.profileManager.refresh() - self.state.refresh(self.instrument) - self.apply_tints() - self.apply_locks() - - def removeParameter(self, fullName: str) -> None: - """Remove the parameter at ``fullName`` (the row's delete button - and the delete_item shortcut both land here). - - While the parameter is the Target of Locks — deleting it drops - them (D3) — a QMessageBox names every Follower that will lose its - Lock and asks for confirmation (plan task 5.6); Cancel returns - without touching the Server. A parameter without Followers is - removed without a dialog.""" - self.removalDialog = None - if not self.instrument.has_param(fullName): - return - try: - followers = self.instrument.followers_of(fullName) - except Exception: - # the Server call failed: fall back to the client-side list - # computed from the state — locked and unlocked alike, the - # Targets compared through relative_path - followers = sorted( - follower - for follower, lock in self.state.locks.items() - if relative_path(lock.target, self.instrument.name) == fullName - ) - if followers: - lines = [] - for follower in followers: - lock = self.state.locks.get(follower) - state = "locked" if lock is not None and lock.locked else "unlocked" - lines.append(f"{follower} ({state})") - box = QtWidgets.QMessageBox(self) - box.setObjectName("removalDialog") - box.setIcon(QtWidgets.QMessageBox.Icon.Question) - box.setWindowTitle("Remove Target?") - # macOS ignores a QMessageBox's window title (windowTitle() - # reads back empty there); tests pin the dialog through its - # object name and text instead. - box.setText( - f"Removing {fullName} also removes the Locks of:\n" - + "\n".join(lines) - ) - box.setStandardButtons( - QtWidgets.QMessageBox.StandardButton.Ok - | QtWidgets.QMessageBox.StandardButton.Cancel - ) - box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) - self.removalDialog = box - clicked = box.exec() - # the box is closed on both paths: the attribute matches its - # docstring again (the tests' QTimer callbacks read it while - # the box is open, so they keep working) - self.removalDialog = None - if clicked != QtWidgets.QMessageBox.StandardButton.Ok: - return - self.instrument.remove_parameter(fullName) - - def addParameter(self, fullName: str, value: Any, unit: str) -> None: - try: - # Validators are commented out until they can be serialized. - self.instrument.add_parameter( - fullName, - initial_value=value, - unit=unit, - ) # vals=vals) - self.parameterCreated.emit() - except Exception as e: - self.parameterCreationError.emit( - f"Could not create parameter.Adding parameter raised{type(e)}: {e.args}" - ) - return - - @QtCore.Slot() - def loadProfile(self) -> None: - profileName = self.profileManager.currentText() - self.instrument.switch_to_profile(profileName) - super().refreshAll() - self.instrument.refresh_profiles() - # a profile load emits no parameter-creation/parameter-deletion - # Broadcasts for the parameters it (re)creates, so the state of the - # Types and Locks must be re-read from the Parameter Manager - self.state.refresh(self.instrument) - self.apply_tints() - self.apply_locks() - - @QtCore.Slot(str, object) - def _on_type_changed( - self, name: str, type_blueprint: Optional[PMTypeBluePrint] - ) -> None: - """Record the change a ``pm-type-update`` Broadcast reports about - the Type ``name`` in the state, then recompute the tints and gutter - bands it may change, and rebuild the Locks panel (its Type Lock - rows depend on the Types).""" - self.state.apply_type(name, type_blueprint) - self.apply_tints() - self.refresh_locks_panel() + gui.shortcutManager.register("lock_to", self._lock_current_item, gui) + gui.shortcutManager.register("unlock_item", self._unlock_current_item, gui) + gui.shortcutManager.register_tooltip("lock_to", gui.view.lockToAction) + gui.shortcutManager.register_tooltip("unlock_item", gui.view.unlockAction) @QtCore.Slot(str, object) def _on_lock_changed( @@ -794,16 +575,16 @@ def _on_lock_changed( Follower's own and every row whose chain of locked Locks reaches it, since locking and unlocking change what ``get`` answers — in the tree and, while it is shown, in the Locks panel.""" - self.state.apply_lock(path, lock) + self.gui.state.apply_lock(path, lock) self.apply_locks() refreshed = [ path, - *followers_reaching(path, self.state.locks, self.instrument.name), + *followers_reaching(path, self.gui.state.locks, self.gui.instrument.name), ] for follower in refreshed: self._refresh_row_widget(follower) - if not self.locksPanel.isHidden(): - self.locksPanel.refresh_values(refreshed) + if not self.gui.locksPanel.isHidden(): + self.gui.locksPanel.refresh_values(refreshed) @QtCore.Slot(object, object) def _on_item_new_value(self, path: object, value: object) -> None: @@ -815,18 +596,18 @@ def _on_item_new_value(self, path: object, value: object) -> None: ``view.onItemNewValue``; this slot handles the rows behind it — and, while the Locks panel is shown, the same paths there.""" followers = followers_reaching( - str(path), self.state.locks, self.instrument.name + str(path), self.gui.state.locks, self.gui.instrument.name ) for follower in followers: self._refresh_row_widget(follower) - if not self.locksPanel.isHidden(): - self.locksPanel.refresh_values([str(path), *followers]) + if not self.gui.locksPanel.isHidden(): + self.gui.locksPanel.refresh_values([str(path), *followers]) def _refresh_row_widget(self, path: str) -> None: """Re-read the parameter behind the row at ``path`` through the Proxy, which pulls the Target's value for a locked Follower, and show it on the row's widget.""" - widget = self.view.delegate.parameters.get(path) + widget = self.gui.view.delegate.parameters.get(path) if widget is None: return try: @@ -850,7 +631,7 @@ def apply_locks(self) -> None: rows on every change is fine — the tree is small — and keeps one clear path. The Locks panel is rebuilt with the same state at the end, but only while it is shown.""" - self._apply_locks_to_rows(self.model.invisibleRootItem()) + self._apply_locks_to_rows(self.gui.model.invisibleRootItem()) self.refresh_locks_panel() def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: @@ -869,10 +650,10 @@ def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: else: lockItem.setText( lock_column_text( - item.name, self.state.locks, self.instrument.name + item.name, self.gui.state.locks, self.gui.instrument.name ) ) - widget = self.view.delegate.parameters.get(item.name) + widget = self.gui.view.delegate.parameters.get(item.name) if widget is not None: self._update_row_lock_widget(item.name, widget) if item.hasChildren(): @@ -889,13 +670,13 @@ def _update_row_lock_widget( if isinstance(widget, LockableParameterWidget) else None ) - lock = self.state.locks.get(path) + lock = self.gui.state.locks.get(path) if lock is None: if button is not None: button.setVisible(False) widget.set_read_only(False) return - target = relative_path(lock.target, self.instrument.name) + target = relative_path(lock.target, self.gui.instrument.name) tooltip = lock_button_tooltip(lock.locked, target) if button is not None: button.setToolTip(tooltip) @@ -913,9 +694,9 @@ def _toggle_lock(self, path: str) -> None: """Toggle the Lock of the parameter at ``path`` (the row's lock button). A refused toggle — relocking would close a cycle (D7) — shows the Server's error text on the row's alert widget.""" - widget = self.view.delegate.parameters.get(path) + widget = self.gui.view.delegate.parameters.get(path) try: - self.instrument.toggle_lock(path) + self.gui.instrument.toggle_lock(path) except Exception as e: if widget is not None: widget.alertWidget.setAlert(str(e)) @@ -925,9 +706,9 @@ def _unlock(self, path: str) -> None: """Unlock the Lock of the parameter at ``path`` (the context menu's "Unlock"). A refused unlock shows the Server's error text on the row's alert widget.""" - widget = self.view.delegate.parameters.get(path) + widget = self.gui.view.delegate.parameters.get(path) try: - self.instrument.unlock(path) + self.gui.instrument.unlock(path) except Exception as e: if widget is not None: widget.alertWidget.setAlert(str(e)) @@ -936,7 +717,7 @@ def _lock_current_item(self) -> None: """The lock_to shortcut (plan task 5.6): arm the target picker for the tree's current parameter row. A submodule row or no selection does nothing.""" - item = self._getCurrentItem() + item = self.gui._getCurrentItem() if item is not None and item.element is not None: self.arm_lock(item.name) @@ -946,31 +727,26 @@ def _unlock_current_item(self) -> None: without a locked Lock does nothing. A refused unlock shows the Server's error text on the row's alert widget, like the context menu's Unlock.""" - item = self._getCurrentItem() + item = self.gui._getCurrentItem() if item is None or item.element is None: return - lock = self.state.locks.get(item.name) + lock = self.gui.state.locks.get(item.name) if lock is None or not lock.locked: return self._unlock(item.name) - def _toggle_tabs(self) -> None: - """The show_types shortcut (plan task 5.6): switch between the - Parameters and Types tabs.""" - self.tabs.setCurrentIndex(1 if self.tabs.currentIndex() == 0 else 0) - @QtCore.Slot() def _update_lock_actions(self) -> None: """Enable the context menu's lock actions for the row the menu was opened on: "Lock to…" for every parameter row, "Unlock" only for a parameter whose Lock in the state is locked.""" - item = self.view.lastSelectedItem + item = self.gui.view.lastSelectedItem is_parameter = item is not None and item.element is not None - self.view.lockToAction.setEnabled(is_parameter) - self.view.unlockAction.setEnabled( + self.gui.view.lockToAction.setEnabled(is_parameter) + self.gui.view.unlockAction.setEnabled( is_parameter - and item.name in self.state.locks # type: ignore[union-attr] - and self.state.locks[item.name].locked # type: ignore[union-attr] + and item.name in self.gui.state.locks # type: ignore[union-attr] + and self.gui.state.locks[item.name].locked # type: ignore[union-attr] ) def arm_lock(self, follower: str) -> None: @@ -979,11 +755,11 @@ def arm_lock(self, follower: str) -> None: ranked like the mock's completer (same relative path inside its Instance first), and the strip shows under the toolbar. Arming while already armed re-arms for the new Follower.""" - parameters = self._model_parameters() - claims = compute_claims(self.state.types, parameters) + parameters = self.gui.model.parameter_units() + claims = compute_claims(self.gui.state.types, parameters) self.armed_follower = follower self.armed_type_lock = None - self.armStrip.arm(follower, rank_lock_targets(follower, parameters, claims)) + self.gui.armStrip.arm(follower, rank_lock_targets(follower, parameters, claims)) def arm_type_lock(self, type_name: str, path: str) -> None: """Arm the target picker for the Type Lock of the entry ``path`` @@ -995,10 +771,10 @@ def arm_type_lock(self, type_name: str, path: str) -> None: entry.""" self.armed_type_lock = (type_name, path) self.armed_follower = None - self.tabs.setCurrentIndex(0) - parameters = self._model_parameters() - claims = compute_claims(self.state.types, parameters) - self.armStrip.arm( + self.gui.tabs.setCurrentIndex(0) + parameters = self.gui.model.parameter_units() + claims = compute_claims(self.gui.state.types, parameters) + self.gui.armStrip.arm( f"type {type_name} \u00b7 {path}", rank_lock_targets("", parameters, claims, arm_rel=path), ) @@ -1015,28 +791,28 @@ def pick_lock_target(self, target: str) -> None: if self.armed_type_lock is not None: type_name, path = self.armed_type_lock try: - skipped = self.instrument.lock_type_parameter( + skipped = self.gui.instrument.lock_type_parameter( type_name, path, target=target ) except Exception as exc: - self.armStrip.show_error(str(exc)) + self.gui.armStrip.show_error(str(exc)) else: if skipped: - self.typesPane.show_entries_note( + self.gui.typesPane.show_entries_note( f"skipped: {', '.join(skipped)}" ) else: # a clean declaration leaves no stale error or # skipped note behind (plan task 5.6) - self.typesPane.reset_entries_note() + self.gui.typesPane.reset_entries_note() self.cancel_arm() return if self.armed_follower is None: return try: - self.instrument.lock(self.armed_follower, target) + self.gui.instrument.lock(self.armed_follower, target) except Exception as exc: - self.armStrip.show_error(str(exc)) + self.gui.armStrip.show_error(str(exc)) else: self.cancel_arm() @@ -1045,7 +821,7 @@ def cancel_arm(self) -> None: of pick: a Follower's Lock or a Type Lock's re-target).""" self.armed_follower = None self.armed_type_lock = None - self.armStrip.disarm() + self.gui.armStrip.disarm() @QtCore.Slot(QtCore.QModelIndex) def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: @@ -1054,22 +830,18 @@ def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: nothing.""" if self.armed_follower is None and self.armed_type_lock is None: return - source_index = self.proxyModel.mapToSource(index) + source_index = self.gui.proxyModel.mapToSource(index) source_index = source_index.sibling(source_index.row(), 0) - item = self.model.itemFromIndex(source_index) + item = self.gui.model.itemFromIndex(source_index) if item is not None and item.element is not None: self.pick_lock_target(item.name) - # ------------------------------------------------------------------ - # the Locks panel (plan task 5.4) - # ------------------------------------------------------------------ - @QtCore.Slot(bool) def _on_locks_action_toggled(self, checked: bool) -> None: """Show or hide the Locks panel with the toolbar action, and rebuild its rows when it becomes visible (a hidden panel costs nothing).""" - self.locksPanel.setVisible(checked) + self.gui.locksPanel.setVisible(checked) if checked: self.refresh_locks_panel() @@ -1080,25 +852,26 @@ def refresh_locks_panel(self) -> None: each row's parameter resolved through the instrument. Runs at the end of :meth:`apply_locks` and of - :meth:`_on_type_changed` — the Type Lock rows depend on the Types — - and when the toolbar action shows the panel, but only while the - panel is shown, so a hidden panel costs nothing.""" - if self.locksPanel.isHidden(): + :meth:`TypesController._on_type_changed` — the Type Lock rows + depend on the Types — and when the toolbar action shows the panel, + but only while the panel is shown, so a hidden panel costs + nothing.""" + if self.gui.locksPanel.isHidden(): return rows = build_lock_rows( - self.state.locks, self.state.types, self.instrument.name + self.gui.state.locks, self.gui.state.types, self.gui.instrument.name ) elements: Dict[str, Any] = {} for path in lock_row_paths(rows): try: - elements[path] = nestedAttributeFromString(self.instrument, path) + elements[path] = nestedAttributeFromString(self.gui.instrument, path) except (AttributeError, RuntimeError) as exc: logger.debug( f"could not resolve the parameter of the Locks panel " f"row {path}: {exc}" ) - self.locksPanel.rebuild( - rows, elements, self.state.types, self.state.locks + self.gui.locksPanel.rebuild( + rows, elements, self.gui.state.types, self.gui.state.locks ) @QtCore.Slot(str) @@ -1107,11 +880,11 @@ def _on_panel_toggle_lock(self, path: str) -> None: parameter at ``path``. A refused toggle shows the Server's error text on the panel's note label.""" try: - self.instrument.toggle_lock(path) + self.gui.instrument.toggle_lock(path) except Exception as exc: - self.locksPanel.show_error(str(exc)) + self.gui.locksPanel.show_error(str(exc)) else: - self.locksPanel.reset_note() + self.gui.locksPanel.reset_note() @QtCore.Slot(str) def _on_panel_remove_lock(self, path: str) -> None: @@ -1119,11 +892,11 @@ def _on_panel_remove_lock(self, path: str) -> None: parameter at ``path``. A refused removal shows the Server's error text on the panel's note label.""" try: - self.instrument.remove_lock(path) + self.gui.instrument.remove_lock(path) except Exception as exc: - self.locksPanel.show_error(str(exc)) + self.gui.locksPanel.show_error(str(exc)) else: - self.locksPanel.reset_note() + self.gui.locksPanel.reset_note() @QtCore.Slot(str, str, str) def _on_panel_lock_all(self, type_name: str, entry: str, target: str) -> None: @@ -1133,16 +906,16 @@ def _on_panel_lock_all(self, type_name: str, entry: str, target: str) -> None: Instance parameters the declaration skips, because they carry a Lock on another Target (D17), are named on the note label.""" try: - skipped = self.instrument.lock_type_parameter( + skipped = self.gui.instrument.lock_type_parameter( type_name, entry, target=target ) except Exception as exc: - self.locksPanel.show_error(str(exc)) + self.gui.locksPanel.show_error(str(exc)) else: if skipped: - self.locksPanel.show_note(f"skipped: {', '.join(skipped)}") + self.gui.locksPanel.show_note(f"skipped: {', '.join(skipped)}") else: - self.locksPanel.reset_note() + self.gui.locksPanel.reset_note() @QtCore.Slot(str, str) def _on_panel_remove_rule(self, type_name: str, entry: str) -> None: @@ -1151,11 +924,11 @@ def _on_panel_remove_rule(self, type_name: str, entry: str) -> None: one. A refused removal shows the Server's error text on the panel's note label.""" try: - self.instrument.unlock_type_parameter(type_name, entry) + self.gui.instrument.unlock_type_parameter(type_name, entry) except Exception as exc: - self.locksPanel.show_error(str(exc)) + self.gui.locksPanel.show_error(str(exc)) else: - self.locksPanel.reset_note() + self.gui.locksPanel.reset_note() @QtCore.Slot() def _lock_selection_from_panel(self) -> None: @@ -1163,11 +936,11 @@ def _lock_selection_from_panel(self) -> None: tree's current parameter row. With no parameter row current, the note label says so and nothing is armed; a successful arm clears a stale error from the note (plan task 5.6).""" - item = self._getCurrentItem() + item = self.gui._getCurrentItem() if item is None or item.element is None: - self.locksPanel.show_error("Select a parameter in the tree first.") + self.gui.locksPanel.show_error("Select a parameter in the tree first.") return - self.locksPanel.reset_note() + self.gui.locksPanel.reset_note() self.arm_lock(item.name) @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) @@ -1177,11 +950,53 @@ def _on_tree_current_changed( """Keep the panel's selected label on the tree's current row: a parameter row shows its path, a submodule row or no selection shows "no parameter selected".""" - item = self._getCurrentItem() + item = self.gui._getCurrentItem() if item is not None and item.element is not None: - self.locksPanel.selectedLabel.setText(item.name) + self.gui.locksPanel.selectedLabel.setText(item.name) else: - self.locksPanel.selectedLabel.setText("no parameter selected") + self.gui.locksPanel.selectedLabel.setText("no parameter selected") + + +class TypesController(QtCore.QObject): + """The Type behaviour of a :class:`ParameterManagerGui` (plan tasks + 5.2 and 5.5): the tree's tints and gutter bands, and the Types tab's + actions. It works on the GUI's widgets and state and wires them in + :meth:`connectSignals`.""" + + def __init__(self, gui: "ParameterManagerGui") -> None: + super().__init__(gui) + self.gui = gui + + def connectSignals(self) -> None: + gui = self.gui + gui.model.typeChanged.connect(self._on_type_changed) + gui.model.structureChanged.connect(self.apply_tints) + # the Types pane (plan task 5.5): a selection change re-renders + # the two panes it drives + gui.typesPane.typeSelected.connect(self._on_pane_type_selected) + gui.typesPane.addTypeRequested.connect(self._on_pane_add_type) + gui.typesPane.addEntryRequested.connect(self._on_pane_add_entry) + gui.typesPane.removeEntryRequested.connect(self._on_pane_remove_entry) + gui.typesPane.setDefaultRequested.connect(self._on_pane_set_default) + gui.typesPane.toggleTypeLockRequested.connect( + self._on_pane_toggle_type_lock + ) + gui.typesPane.addNestedRequested.connect(self._on_pane_add_nested) + gui.typesPane.removeNestedRequested.connect(self._on_pane_remove_nested) + gui.typesPane.addInstanceRequested.connect(self._on_pane_add_instance) + gui.typesPane.showInstanceRequested.connect(self._on_pane_show_instance) + + @QtCore.Slot(str, object) + def _on_type_changed( + self, name: str, type_blueprint: Optional[PMTypeBluePrint] + ) -> None: + """Record the change a ``pm-type-update`` Broadcast reports about + the Type ``name`` in the state, then recompute the tints and gutter + bands it may change, and rebuild the Locks panel (its Type Lock + rows depend on the Types).""" + self.gui.state.apply_type(name, type_blueprint) + self.apply_tints() + self.gui.locksController.refresh_locks_panel() @QtCore.Slot() def apply_tints(self) -> None: @@ -1194,31 +1009,11 @@ def apply_tints(self) -> None: depends on which parameters exist. The Types pane rebuilds from the same state at the end (plan task 5.5). """ - self.typePalette.sync(self.state.types) - claims = compute_claims(self.state.types, self._model_parameters()) - self._apply_tints_to_rows(self.model.invisibleRootItem(), claims) + self.gui.typePalette.sync(self.gui.state.types) + claims = compute_claims(self.gui.state.types, self.gui.model.parameter_units()) + self._apply_tints_to_rows(self.gui.model.invisibleRootItem(), claims) self.refresh_types_pane() - def _model_parameters(self) -> Dict[str, str]: - """Every parameter row of the source model as ``{path: unit}``.""" - parameters: Dict[str, str] = {} - self._collect_parameters(self.model.invisibleRootItem(), parameters) - return parameters - - def _collect_parameters( - self, parent: QtGui.QStandardItem, parameters: Dict[str, str] - ) -> None: - for row in range(parent.rowCount()): - item = parent.child(row, 0) - if item is None: - continue - if item.element is not None: # type: ignore[attr-defined] - # a parameter row; a submodule row's element is None - unitItem = parent.child(row, 1) - parameters[item.name] = "" if unitItem is None else unitItem.text() - if item.hasChildren(): - self._collect_parameters(item, parameters) - def _apply_tints_to_rows( self, parent: QtGui.QStandardItem, claims: Dict[str, Claim] ) -> None: @@ -1235,7 +1030,7 @@ def _apply_tints_to_rows( assert gutterItem is not None claim = claims.get(item.name) colours = ( - self.typePalette.colours(claim.type) if claim is not None else None + self.gui.typePalette.colours(claim.type) if claim is not None else None ) if claim is not None and colours is not None: # claimed rows carry the Claiming Type's tint, alternating @@ -1256,10 +1051,6 @@ def _apply_tints_to_rows( if item.hasChildren(): self._apply_tints_to_rows(item, claims) - # ------------------------------------------------------------------ - # the Types pane (plan task 5.5) - # ------------------------------------------------------------------ - @QtCore.Slot() def refresh_types_pane(self) -> None: """Rebuild the Types pane's three panes from the client-side state @@ -1272,16 +1063,16 @@ def refresh_types_pane(self) -> None: returns (the Broadcast arrives on top of that; a double rebuild is fine). The pane keeps the selected Type across rebuilds and drops the selection when the Type is gone.""" - self.typesPane.rebuild( - self.state.types, self._model_parameters(), self.typePalette + self.gui.typesPane.rebuild( + self.gui.state.types, self.gui.model.parameter_units(), self.gui.typePalette ) @QtCore.Slot(str) def _on_pane_type_selected(self, name: str) -> None: """The Types pane's selected Type changed: re-render the entries and Instances panes for it.""" - self.typesPane.refresh_selected_panes( - self.state.types, self._model_parameters(), self.typePalette + self.gui.typesPane.refresh_selected_panes( + self.gui.state.types, self.gui.model.parameter_units(), self.gui.typePalette ) @QtCore.Slot(str) @@ -1291,13 +1082,13 @@ def _on_pane_add_type(self, name: str) -> None: Type is selected once the pane rebuilds (the ``pm-type-update`` Broadcast brings it into the state).""" try: - self.instrument.add_type(name) + self.gui.instrument.add_type(name) except Exception as exc: - self.typesPane.show_type_error(str(exc)) + self.gui.typesPane.show_type_error(str(exc)) else: - self.typesPane.reset_type_note() - self.typesPane.newTypeEdit.clear() - self.typesPane.select_type(name) + self.gui.typesPane.reset_type_note() + self.gui.typesPane.newTypeEdit.clear() + self.gui.typesPane.select_type(name) self.refresh_types_pane() @QtCore.Slot(str, str, str, str) @@ -1308,16 +1099,16 @@ def _on_pane_add_entry( (``None`` when the text is empty) and unit (D11, D13). A refused edit shows the Server's error text on the entries pane's note.""" try: - self.instrument.add_type_parameter( + self.gui.instrument.add_type_parameter( type_name, path, default=parse_default_text(default_text), unit=unit ) except Exception as exc: - self.typesPane.show_entries_error(str(exc)) + self.gui.typesPane.show_entries_error(str(exc)) else: - self.typesPane.reset_entries_note() - self.typesPane.entryNameEdit.clear() - self.typesPane.entryDefaultEdit.clear() - self.typesPane.entryUnitEdit.clear() + self.gui.typesPane.reset_entries_note() + self.gui.typesPane.entryNameEdit.clear() + self.gui.typesPane.entryDefaultEdit.clear() + self.gui.typesPane.entryUnitEdit.clear() self.refresh_types_pane() @QtCore.Slot(str, str) @@ -1326,11 +1117,11 @@ def _on_pane_remove_entry(self, type_name: str, path: str) -> None: only (D13) — the Instances keep the parameter. A refused removal shows the Server's error text on the entries pane's note.""" try: - self.instrument.remove_type_parameter(type_name, path) + self.gui.instrument.remove_type_parameter(type_name, path) except Exception as exc: - self.typesPane.show_entries_error(str(exc)) + self.gui.typesPane.show_entries_error(str(exc)) else: - self.typesPane.reset_entries_note() + self.gui.typesPane.reset_entries_note() self.refresh_types_pane() @QtCore.Slot(str, str, str) @@ -1340,13 +1131,13 @@ def _on_pane_set_default(self, type_name: str, path: str, text: str) -> None: refused set shows the Server's error text on the entries pane's note.""" try: - self.instrument.set_type_parameter_default( + self.gui.instrument.set_type_parameter_default( type_name, path, parse_default_text(text) ) except Exception as exc: - self.typesPane.show_entries_error(str(exc)) + self.gui.typesPane.show_entries_error(str(exc)) else: - self.typesPane.reset_entries_note() + self.gui.typesPane.reset_entries_note() self.refresh_types_pane() @QtCore.Slot(str, str) @@ -1356,23 +1147,23 @@ def _on_pane_toggle_type_lock(self, type_name: str, path: str) -> None: the rule while it has one (D17). A refused toggle shows the Server's error text on the entries pane's note; the Instance parameters a declaration skips (D17) are named on it.""" - blueprint = self.state.types.get(type_name) + blueprint = self.gui.state.types.get(type_name) target = None if blueprint is not None: target = blueprint.parameters.get(path, {}).get("target") try: if target is None: - skipped = self.instrument.lock_type_parameter(type_name, path) + skipped = self.gui.instrument.lock_type_parameter(type_name, path) else: - self.instrument.unlock_type_parameter(type_name, path) + self.gui.instrument.unlock_type_parameter(type_name, path) skipped = [] except Exception as exc: - self.typesPane.show_entries_error(str(exc)) + self.gui.typesPane.show_entries_error(str(exc)) else: if skipped: - self.typesPane.show_entries_note(f"skipped: {', '.join(skipped)}") + self.gui.typesPane.show_entries_note(f"skipped: {', '.join(skipped)}") else: - self.typesPane.reset_entries_note() + self.gui.typesPane.reset_entries_note() self.refresh_types_pane() @QtCore.Slot(str, str, str) @@ -1383,12 +1174,12 @@ def _on_pane_add_nested( the submodule (D11, D13). A refused edit shows the Server's error text on the entries pane's note.""" try: - self.instrument.add_nested_type(type_name, submodule, nested) + self.gui.instrument.add_nested_type(type_name, submodule, nested) except Exception as exc: - self.typesPane.show_entries_error(str(exc)) + self.gui.typesPane.show_entries_error(str(exc)) else: - self.typesPane.reset_entries_note() - self.typesPane.nestedAtEdit.clear() + self.gui.typesPane.reset_entries_note() + self.gui.typesPane.nestedAtEdit.clear() self.refresh_types_pane() @QtCore.Slot(str, str) @@ -1397,11 +1188,11 @@ def _on_pane_remove_nested(self, type_name: str, submodule: str) -> None: (D13) — the Instances keep the parameters. A refused removal shows the Server's error text on the entries pane's note.""" try: - self.instrument.remove_nested_type(type_name, submodule) + self.gui.instrument.remove_nested_type(type_name, submodule) except Exception as exc: - self.typesPane.show_entries_error(str(exc)) + self.gui.typesPane.show_entries_error(str(exc)) else: - self.typesPane.reset_entries_note() + self.gui.typesPane.reset_entries_note() self.refresh_types_pane() @QtCore.Slot(str, str) @@ -1410,12 +1201,12 @@ def _on_pane_add_instance(self, type_name: str, name: str) -> None: Type (D14). A refused creation shows the Server's error text on the instances pane's note.""" try: - self.instrument.add_instance(type_name, name) + self.gui.instrument.add_instance(type_name, name) except Exception as exc: - self.typesPane.show_instances_error(str(exc)) + self.gui.typesPane.show_instances_error(str(exc)) else: - self.typesPane.reset_instances_note() - self.typesPane.newInstanceEdit.clear() + self.gui.typesPane.reset_instances_note() + self.gui.typesPane.newInstanceEdit.clear() self.refresh_types_pane() @QtCore.Slot(str, str) @@ -1424,16 +1215,16 @@ def _on_pane_show_instance(self, type_name: str, instance: str) -> None: clear the filter, expand the tree and select the Instance's first parameter row — the first effective entry under it, the submodule row as the fallback — scrolled into view.""" - self.tabs.setCurrentIndex(0) - self.lineEdit.clear() - self.view.expandAll() - blueprint = self.state.types.get(type_name) + self.gui.tabs.setCurrentIndex(0) + self.gui.lineEdit.clear() + self.gui.view.expandAll() + blueprint = self.gui.state.types.get(type_name) candidates = [instance] if blueprint is not None and blueprint.effective: first = sorted(blueprint.effective, key=lambda entry: entry.split("."))[0] candidates.insert(0, f"{instance}.{first}") for path in candidates: - matches = self.model.findItems( + matches = self.gui.model.findItems( path, cast( "QtCore.Qt.MatchFlags", @@ -1444,14 +1235,259 @@ def _on_pane_show_instance(self, type_name: str, instance: str) -> None: ) if not matches: continue - proxy_index = self.proxyModel.mapFromSource( - self.model.indexFromItem(matches[0]) + proxy_index = self.gui.proxyModel.mapFromSource( + self.gui.model.indexFromItem(matches[0]) ) if proxy_index.isValid(): - self.view.setCurrentIndex(proxy_index) - self.view.scrollTo(proxy_index) + self.gui.view.setCurrentIndex(proxy_index) + self.gui.view.scrollTo(proxy_index) break + +class ParameterManagerGui(InstrumentParameters): + #: Signal(str) -- + #: emitted when there's an error during parameter creation. + parameterCreationError = QtCore.Signal(str) + + #: Signal() -- + #: emitted when a parameter was created successfully + parameterCreated = QtCore.Signal() + + def __init__( + self, + instrument: Union[ProxyInstrument, ParameterManager], + parent: Optional[QtWidgets.QWidget] = None, + **kwargs: Any, + ) -> None: + super().__init__( + instrument, + parent=None, + viewType=ParameterManagerTreeView, + callSignals=False, + modelType=ModelParameterManager, + **kwargs, + ) + # The client-side cache of the Parameter Manager's Types and Locks. + # Created before connectSignals, which wires the model's Broadcast + # routing into it. + self.state = PMState() + # The tint palette: maps each Type to its slot in TINT_PALETTE; the + # view's gutter delegate reads the colours from it. + self.typePalette = TypePalette() + self.view.gutterDelegate.typePalette = self.typePalette + self.profileManager = ProfilesManager(parent=self) + self.addParam = AddParameterWidget(parent=self) + layout = self.layout() + assert isinstance(layout, QtWidgets.QVBoxLayout) + layout.insertWidget(0, self.profileManager) + layout.addWidget(self.addParam) + # The Locks panel (plan task 5.4) sits right of the tree in a + # splitter: the view keeps its identity, so every existing layout + # consumer and test keeps working. The panel starts hidden and + # costs nothing until the toolbar action shows it. + self.locksPanel = LocksPanel(self.instrument.name, parent=self) + view_index = layout.indexOf(self.view) + layout.removeWidget(self.view) + self.locksSplitter = QtWidgets.QSplitter( + QtCore.Qt.Orientation.Horizontal, self + ) + self.locksSplitter.addWidget(self.view) + self.locksSplitter.addWidget(self.locksPanel) + self.locksSplitter.setStretchFactor(0, 3) + self.locksSplitter.setStretchFactor(1, 2) + layout.insertWidget(view_index, self.locksSplitter) + self.locksPanel.setVisible(False) + # The existing content becomes tab 0 of the tab widget; tab 1 + # holds the Types pane (plan task 5.5). + self.parametersTab = QtWidgets.QWidget(self) + self.parametersTab.setLayout(self.layout()) + self.typesTab = QtWidgets.QWidget(self) + typesTabLayout = QtWidgets.QVBoxLayout(self.typesTab) + typesTabLayout.setContentsMargins(0, 0, 0, 0) + self.typesPane = TypesPane(self.instrument.name, parent=self.typesTab) + typesTabLayout.addWidget(self.typesPane) + self.tabs = QtWidgets.QTabWidget(self) + self.tabs.addTab(self.parametersTab, "Parameters") + self.tabs.addTab(self.typesTab, "Types") + outerLayout = QtWidgets.QVBoxLayout(self) + outerLayout.setContentsMargins(0, 0, 0, 0) + outerLayout.addWidget(self.tabs) + # The arm strip sits right under the toolbar and stays hidden until + # a Lock's Target is being picked (plan task 5.3); the Locks + # controller keeps what the pick is armed for. + self.armStrip = LockArmStrip(self.parametersTab) + parametersLayout = self.parametersTab.layout() + assert isinstance(parametersLayout, QtWidgets.QVBoxLayout) + toolbar_index = parametersLayout.indexOf(self.toolbar) + parametersLayout.insertWidget(toolbar_index + 1, self.armStrip) + self.armStrip.setVisible(False) + # Escape over the tree cancels the pick too (harmless when the + # strip is not armed); the Locks controller connects it + self.viewEscShortcut = QtWidgets.QShortcut( + QtGui.QKeySequence("Escape"), self.view + ) + self.viewEscShortcut.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) + # The confirmation dialog for removing a Lock Target (plan task + # 5.6), kept on the GUI so tests can drive it; ``None`` while no + # removal that needs one is in flight. + self.removalDialog: Optional[QtWidgets.QMessageBox] = None + # The Types tab and tints, and the Lock column, arm strip and Locks + # panel, each run through a controller that wires its own widgets + self.typesController = TypesController(self) + self.locksController = LocksController(self) + self.connectSignals() + self.loadProfile() + + def connectSignals(self) -> None: + super().connectSignals() + self.view.delegate.removeParameter.connect(self.removeParameter) + self.addParam.newParamRequested.connect(self.addParameter) + self.parameterCreationError.connect(self.addParam.setError) + self.parameterCreated.connect(self.addParam.clear) + self.profileManager.indexChanged.connect(self.loadProfile) + # the tints run before the Lock pass on a structural change + self.typesController.connectSignals() + self.locksController.connectSignals() + self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) + self.shortcutManager.register("clear_add", self.addParam.clear, self) + self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) + self.shortcutManager.register("load_items", self.loadFromFile, self) + self.shortcutManager.register("save_items", self.saveToFile, self) + self.shortcutManager.register("show_types", self._toggle_tabs, self) + + @QtCore.Slot() + def _deleteCurrentItem(self) -> None: + item = self._getCurrentItem() + if item is not None: + self.removeParameter(item.name) + + def makeToolbar(self) -> QtWidgets.QToolBar: + toolbar = super().makeToolbar() + + toolbar.addSeparator() + + loadParamAction = toolbar.addAction( + QtGui.QIcon(":/icons/load.svg"), + "Load parameters from file", + ) + loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("load_items", loadParamAction) + + saveParamAction = toolbar.addAction( + QtGui.QIcon(":/icons/save.svg"), + "Save parameters to file", + ) + saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("save_items", saveParamAction) + + # the Locks panel toggle (plan task 5.4); the toggled connection + # and the shortcut are wired in connectSignals, where the panel + # exists + self.locksAction = toolbar.addAction( + QtGui.QIcon(":/icons/lock.svg"), + "Show the Locks panel", + ) + self.locksAction.setCheckable(True) + self.shortcutManager.register_tooltip("toggle_locks", self.locksAction) + + return toolbar + + def refreshAll(self) -> None: + super().refreshAll() + self.instrument.refresh_profiles() + self.profileManager.refresh() + self.state.refresh(self.instrument) + self.typesController.apply_tints() + self.locksController.apply_locks() + + def removeParameter(self, fullName: str) -> None: + """Remove the parameter at ``fullName`` (the row's delete button + and the delete_item shortcut both land here). + + While the parameter is the Target of Locks — deleting it drops + them (D3) — a QMessageBox names every Follower that will lose its + Lock and asks for confirmation (plan task 5.6); Cancel returns + without touching the Server. A parameter without Followers is + removed without a dialog.""" + self.removalDialog = None + if not self.instrument.has_param(fullName): + return + try: + followers = self.instrument.followers_of(fullName) + except Exception: + # the Server call failed: fall back to the client-side list + # computed from the state — locked and unlocked alike, the + # Targets compared through relative_path + followers = sorted( + follower + for follower, lock in self.state.locks.items() + if relative_path(lock.target, self.instrument.name) == fullName + ) + if followers: + lines = [] + for follower in followers: + lock = self.state.locks.get(follower) + state = "locked" if lock is not None and lock.locked else "unlocked" + lines.append(f"{follower} ({state})") + box = QtWidgets.QMessageBox(self) + box.setObjectName("removalDialog") + box.setIcon(QtWidgets.QMessageBox.Icon.Question) + box.setWindowTitle("Remove Target?") + # macOS ignores a QMessageBox's window title (windowTitle() + # reads back empty there); tests pin the dialog through its + # object name and text instead. + box.setText( + f"Removing {fullName} also removes the Locks of:\n" + + "\n".join(lines) + ) + box.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel + ) + box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + self.removalDialog = box + clicked = box.exec() + # the box is closed on both paths: the attribute matches its + # docstring again (the tests' QTimer callbacks read it while + # the box is open, so they keep working) + self.removalDialog = None + if clicked != QtWidgets.QMessageBox.StandardButton.Ok: + return + self.instrument.remove_parameter(fullName) + + def addParameter(self, fullName: str, value: Any, unit: str) -> None: + try: + # Validators are commented out until they can be serialized. + self.instrument.add_parameter( + fullName, + initial_value=value, + unit=unit, + ) # vals=vals) + self.parameterCreated.emit() + except Exception as e: + self.parameterCreationError.emit( + f"Could not create parameter.Adding parameter raised{type(e)}: {e.args}" + ) + return + + @QtCore.Slot() + def loadProfile(self) -> None: + profileName = self.profileManager.currentText() + self.instrument.switch_to_profile(profileName) + super().refreshAll() + self.instrument.refresh_profiles() + # a profile load emits no parameter-creation/parameter-deletion + # Broadcasts for the parameters it (re)creates, so the state of the + # Types and Locks must be re-read from the Parameter Manager + self.state.refresh(self.instrument) + self.typesController.apply_tints() + self.locksController.apply_locks() + + def _toggle_tabs(self) -> None: + """The show_types shortcut (plan task 5.6): switch between the + Parameters and Types tabs.""" + self.tabs.setCurrentIndex(1 if self.tabs.currentIndex() == 0 else 0) + @QtCore.Slot() def loadFromFile(self, loadFile: Optional[str] = None) -> None: try: diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 9bff146..0b0a7b1 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -1258,7 +1258,7 @@ def test_arm_via_context_menu_pick_a_row_and_toggle( assert not gui.armStrip.isHidden() assert gui.armStrip.label.text() == "Target for q01.IF" - assert gui.armed_follower == "q01.IF" + assert gui.locksController.armed_follower == "q01.IF" candidates = gui.armStrip.completerModel.stringList() assert "q02.IF" in candidates assert "q01.IF" not in candidates @@ -1271,7 +1271,7 @@ def test_arm_via_context_menu_pick_a_row_and_toggle( timeout=BROADCAST_TIMEOUT, ) assert gui.armStrip.isHidden() - assert gui.armed_follower is None + assert gui.locksController.armed_follower is None # the pm-lock-update Broadcast repaints the Lock column and the # lock button, and renders the locked Follower read-only @@ -1364,14 +1364,14 @@ def test_a_cycle_attempt_shows_the_error_and_stays_armed( timeout=BROADCAST_TIMEOUT, ) - gui.arm_lock("q02.IF") - assert gui.armed_follower == "q02.IF" + gui.locksController.arm_lock("q02.IF") + assert gui.locksController.armed_follower == "q02.IF" assert not gui.armStrip.isHidden() - gui.pick_lock_target("q01.IF") + gui.locksController.pick_lock_target("q01.IF") assert "cycle" in gui.armStrip.errorLabel.text() assert not gui.armStrip.isHidden() - assert gui.armed_follower == "q02.IF" + assert gui.locksController.armed_follower == "q02.IF" assert pm.get_lock("q02.IF") is None # Escape over the tree disarms the pick too (the view's Escape @@ -1382,17 +1382,17 @@ def test_a_cycle_attempt_shows_the_error_and_stays_armed( qtbot.wait(20) qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_Escape) assert gui.armStrip.isHidden() - assert gui.armed_follower is None + assert gui.locksController.armed_follower is None # re-arm: Escape in the strip's line edit disarms as well - gui.arm_lock("q02.IF") - assert gui.armed_follower == "q02.IF" + gui.locksController.arm_lock("q02.IF") + assert gui.locksController.armed_follower == "q02.IF" gui.armStrip.activateWindow() gui.armStrip.lineEdit.setFocus() qtbot.wait(20) qtbot.keyClick(gui.armStrip.lineEdit, QtCore.Qt.Key.Key_Escape) assert gui.armStrip.isHidden() - assert gui.armed_follower is None + assert gui.locksController.armed_follower is None finally: gui.model.stopListener() @@ -2060,12 +2060,12 @@ def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_po ) gui.locksPanel.lockSelectionButton.click() - assert gui.armed_follower == "other.x" + assert gui.locksController.armed_follower == "other.x" assert not gui.armStrip.isHidden() # a fresh pick, then a submodule row: the label shows that no # parameter is selected and pressing arms nothing - gui.cancel_arm() + gui.locksController.cancel_arm() source_index = gui.model.indexFromItem(_row_items(gui, "other")[0]) gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) qtbot.waitUntil( @@ -2077,7 +2077,7 @@ def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_po "Select a parameter in the tree first." in gui.locksPanel.noteLabel.text() ) - assert gui.armed_follower is None + assert gui.locksController.armed_follower is None assert gui.armStrip.isHidden() # a successful arm clears the stale error from the note @@ -2085,7 +2085,7 @@ def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_po source_index = gui.model.indexFromItem(_row_items(gui, "other.x")[0]) gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) gui.locksPanel.lockSelectionButton.click() - assert gui.armed_follower == "other.x" + assert gui.locksController.armed_follower == "other.x" assert gui.locksPanel.noteLabel.text() == LOCK_PANEL_NOTE finally: gui.model.stopListener() @@ -2838,31 +2838,31 @@ def _state_target(): gui.typesPane.entryWidgets["IF"]["retarget"].click() assert gui.tabs.currentIndex() == 0 assert gui.armStrip.label.text() == "Target for type qubit · IF" - assert gui.armed_type_lock == ("qubit", "IF") - assert gui.armed_follower is None + assert gui.locksController.armed_type_lock == ("qubit", "IF") + assert gui.locksController.armed_follower is None # a Target the Server refuses: the error text on the strip, which # stays armed, and the entry's Target unchanged on the Server - gui.pick_lock_target("no.such.path") + gui.locksController.pick_lock_target("no.such.path") qtbot.waitUntil( lambda: "no.such.path" in gui.armStrip.errorLabel.text(), timeout=BROADCAST_TIMEOUT, ) assert not gui.armStrip.errorLabel.isHidden() assert not gui.armStrip.isHidden() - assert gui.armed_type_lock == ("qubit", "IF") - assert gui.armed_follower is None + assert gui.locksController.armed_type_lock == ("qubit", "IF") + assert gui.locksController.armed_follower is None assert pm.get_type("qubit").parameters["IF"]["target"] == globals_target - gui.pick_lock_target("tshared") + gui.locksController.pick_lock_target("tshared") qtbot.waitUntil( lambda: pm.get_type("qubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared", timeout=BROADCAST_TIMEOUT, ) assert gui.armStrip.isHidden() - assert gui.armed_type_lock is None - assert gui.armed_follower is None + assert gui.locksController.armed_type_lock is None + assert gui.locksController.armed_follower is None finally: gui.model.stopListener() @@ -2904,16 +2904,16 @@ def test_the_types_tab_names_skipped_locks_on_the_note( # a clean Type Lock re-target with nothing skipped resets the # note (plan task 5.6); re-targeting to the Follower's own Target # skips nothing - gui.arm_type_lock("qubit", "IF") - assert gui.armed_type_lock == ("qubit", "IF") - gui.pick_lock_target("tshared") + gui.locksController.arm_type_lock("qubit", "IF") + assert gui.locksController.armed_type_lock == ("qubit", "IF") + gui.locksController.pick_lock_target("tshared") qtbot.waitUntil( lambda: pm.get_type("qubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared", timeout=BROADCAST_TIMEOUT, ) assert gui.typesPane.entriesNote.text() == "" - assert gui.armed_type_lock is None + assert gui.locksController.armed_type_lock is None finally: gui.model.stopListener() @@ -3268,9 +3268,9 @@ def test_the_lock_shortcuts_arm_unlock_and_switch_tabs( gui.view.setFocus() qtbot.wait(20) qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_L, control) - assert gui.armed_follower == "q01.IF" + assert gui.locksController.armed_follower == "q01.IF" assert not gui.armStrip.isHidden() - gui.cancel_arm() + gui.locksController.cancel_arm() # Ctrl+U on the locked Follower unlocks it on the Server. Hiding # the armed strip hands focus to the next row editor, and the @@ -3295,7 +3295,7 @@ def test_the_lock_shortcuts_arm_unlock_and_switch_tabs( source_index = gui.model.indexFromItem(_row_items(gui, "q01")[0]) gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_L, control) - assert gui.armed_follower is None + assert gui.locksController.armed_follower is None assert gui.armStrip.isHidden() # Ctrl+Shift+Y toggles between the tabs From fd7f97e7d219757b1c6eaea4991616ba70a654ba Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 17:03:32 -0500 Subject: [PATCH 100/107] Keep the Parameter Manager tree tall and the add strip at the bottom Moving the tree into the Locks splitter re-inserted it without a stretch factor, so the tree and the add-parameter strip split the spare height and a band of empty space opened under the tree. The splitter now takes stretch 1 and the strip a fixed height. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../gui/parameter_manager/widget.py | 8 +++++- test/pytest/test_pm_gui.py | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py index 4f1c2b5..8e82b13 100644 --- a/src/instrumentserver/gui/parameter_manager/widget.py +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -188,6 +188,10 @@ def __init__( layout.addWidget(self.clearButton, 0, 7, 1, 1) self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Fixed, + ) self.invalidParamRequested.connect(self.setError) @QtCore.Slot() @@ -1295,7 +1299,9 @@ def __init__( self.locksSplitter.addWidget(self.locksPanel) self.locksSplitter.setStretchFactor(0, 3) self.locksSplitter.setStretchFactor(1, 2) - layout.insertWidget(view_index, self.locksSplitter) + # Stretch 1 so the tree takes all spare height and the Add strip + # stays pinned to the bottom. + layout.insertWidget(view_index, self.locksSplitter, 1) self.locksPanel.setVisible(False) # The existing content becomes tab 0 of the tab widget; tab 1 # holds the Types pane (plan task 5.5). diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 0b0a7b1..002a82b 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -1699,6 +1699,32 @@ def _panel_child_paths(gui, path): return [item.child(row, 0).data(LOCK_ROW_ROLE) for row in range(item.rowCount())] +def test_the_tree_takes_the_spare_height_and_the_add_strip_sits_at_the_bottom( + qtbot, pm, server_port +): + """The tree (inside the Locks splitter) grows with the window, and the + add-parameter strip keeps its own height at the bottom of the + Parameters tab instead of sharing the spare height with the tree.""" + gui = _make_gui(qtbot, pm, server_port) + try: + gui.resize(900, 800) + gui.show() + qtbot.waitExposed(gui) + tab = gui.parametersTab + add_strip = gui.addParam + assert add_strip.height() == add_strip.sizeHint().height() + margin = tab.layout().contentsMargins().bottom() + assert tab.height() - add_strip.geometry().bottom() - 1 == margin + # nothing but the layout spacing between the tree and the strip + spacing = tab.layout().spacing() + assert ( + add_strip.geometry().top() - gui.locksSplitter.geometry().bottom() - 1 + == spacing + ) + finally: + gui.model.stopListener() + + def test_the_locks_action_toggles_the_panel(qtbot, pm, server_port): """The toolbar action is checkable and unchecked, the panel starts hidden as the splitter's second pane, and the shortcut is registered; From 0657e449abdec33ec8cbcfda2cc40a3203ac7f24 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 17:03:32 -0500 Subject: [PATCH 101/107] Stop the open Locks panel from asking the Server in a loop The Server broadcasts a parameter-call for every get, and the model hands it on as a new value. With the Locks panel open, _on_item_new_value answered that with a fresh get of the same path (a locked Follower's label), which broadcast again: thousands of requests a second while nothing happened. The slot now paints the tree's Followers and the panel's rows with the Broadcast's value (a locked Follower reads its Target's value, D3) through the new LocksPanel.show_value; a Lock change still re-reads its rows once, and the Broadcasts that causes only repaint. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../gui/parameter_manager/panels.py | 19 +++++++ .../gui/parameter_manager/widget.py | 20 +++++--- test/pytest/test_pm_gui.py | 51 +++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/instrumentserver/gui/parameter_manager/panels.py b/src/instrumentserver/gui/parameter_manager/panels.py index cbccd7d..575afaf 100644 --- a/src/instrumentserver/gui/parameter_manager/panels.py +++ b/src/instrumentserver/gui/parameter_manager/panels.py @@ -406,6 +406,25 @@ def refresh_values(self, paths: Iterable[str]) -> None: "Object is not being shown right now." ) + def show_value(self, paths: Iterable[str], value: Any) -> None: + """Show ``value`` on every named row the panel holds, without + asking the Server: the value a Broadcast carried. A row whose + widget is gone — a rebuild replaced it — is skipped.""" + for path in paths: + entry = self.rowWidgets.get(path) + if entry is None: + continue + try: + if entry.get("editor") is not None: + entry["editor"]._setMethod(value) + elif entry.get("label") is not None: + entry["label"].setText(str(value)) + except RuntimeError: + logger.debug( + f"Could not show the value of {path}. " + "Object is not being shown right now." + ) + def show_error(self, text: str) -> None: """Show an action error (the mock's ``lockError``) in red on the note label.""" diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py index 8e82b13..8c08254 100644 --- a/src/instrumentserver/gui/parameter_manager/widget.py +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -593,19 +593,25 @@ def _on_lock_changed( @QtCore.Slot(object, object) def _on_item_new_value(self, path: object, value: object) -> None: """Repaint every Follower whose locked Lock chain reaches the - parameter a ``parameter-update`` Broadcast names (D3: a locked - Follower answers ``get`` with the Target's value, and the - Parameter Manager emits nothing for values). The Broadcast's own - row is refreshed by the base wiring to + parameter a ``parameter-update`` or ``parameter-call`` Broadcast + names (D3: a locked Follower answers ``get`` with the Target's + value, and the Parameter Manager emits nothing for values). The + Broadcast's own row is refreshed by the base wiring to ``view.onItemNewValue``; this slot handles the rows behind it — - and, while the Locks panel is shown, the same paths there.""" + and, while the Locks panel is shown, the same paths there. + + Every row is painted with the Broadcast's ``value``, never a fresh + ``get``: the Server broadcasts a ``parameter-call`` for every + ``get``, so a ``get`` here would bring this slot back for the same + path, forever.""" followers = followers_reaching( str(path), self.gui.state.locks, self.gui.instrument.name ) for follower in followers: - self._refresh_row_widget(follower) + if follower in self.gui.view.delegate.parameters: + self.gui.view.onItemNewValue(follower, value) if not self.gui.locksPanel.isHidden(): - self.gui.locksPanel.refresh_values([str(path), *followers]) + self.gui.locksPanel.show_value([str(path), *followers], value) def _refresh_row_widget(self, path: str) -> None: """Re-read the parameter behind the row at ``path`` through the diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 002a82b..fbfc796 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -2169,6 +2169,57 @@ def _unlocked_toggle_back(): gui.model.stopListener() + +def test_an_open_panel_asks_the_server_nothing_while_idle( + qtbot, pm, second_client, server_port +): + """The Server broadcasts a parameter-call for every ``get``, so a + handler that answers a value Broadcast with a ``get`` feeds itself: an + open panel with a locked Follower used to ask the Server thousands of + times a second. With the panel open and nothing happening, the GUI + must send no request at all, and a second Client's Target update must + still reach the Follower's label through the Broadcast's value.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + second_pm.lock("q01.IF", "q02.IF") + second_pm.lock("q03.IF", "q01.IF") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: "q03.IF" in gui.locksPanel.rowWidgets, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.wait(300) # let the Broadcasts the panel's rebuild caused land + + asks = [] + original_ask = pm.cli.ask + + def counting_ask(message): + asks.append(message) + return original_ask(message) + + pm.cli.ask = counting_ask + try: + qtbot.wait(1000) + assert asks == [] + + second_pm.update() # it was made before the parameters existed + second_pm.q02.IF(7.5) + qtbot.waitUntil( + lambda: gui.locksPanel.rowWidgets["q03.IF"]["label"].text() + == "7.5", + timeout=BROADCAST_TIMEOUT, + ) + assert gui.locksPanel.rowWidgets["q01.IF"]["label"].text() == "7.5" + assert asks == [] + finally: + del pm.cli.ask + finally: + gui.model.stopListener() + # --------------------------------------------------------------------------- # plan task 5.5: the Types tab (and the parameter-creation branch fix) # --------------------------------------------------------------------------- From f5670f5e3da1b9c39e37be28d88e070f1c27ba8b Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 17:03:32 -0500 Subject: [PATCH 102/107] Forget a parameter editor when Qt deletes it Qt deletes a row's persistent editor when the filter or the trash toggle hides the row, but ParameterDelegate.parameters kept the dead widget. The Parameter Manager re-applies the Locks on every filter change, so a filter that hid a locked row ("LO") crashed with "wrapped C/C++ object of type QPushButton has been deleted". The delegate now drops the entry on the editor's destroyed signal (unless a newer editor replaced it), and onItemNewValue skips a row that has no editor. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/instrumentserver/gui/instruments.py | 18 +++++++++++++- test/pytest/test_pm_gui.py | 31 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 073b437..64970f7 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -177,6 +177,13 @@ def createEditor( # type: ignore[override] ret = self.makeParameterWidget(item, widget) self.parameters[item.name] = ret # type: ignore[attr-defined] + # Qt deletes the editor when its row is hidden (the filter, the + # trash toggle); forget it then, so nobody touches a dead widget + ret.destroyed.connect( + lambda _=None, name=item.name, editor=ret: self._forgetEditor( # type: ignore[attr-defined] + name, editor + ) + ) ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] if self.navFilter is not None: @@ -197,6 +204,12 @@ def createEditor( # type: ignore[override] # logger.warning(f"Failed to get value for parameter {element.name}: {e}") return ret + def _forgetEditor(self, name: str, editor: QtWidgets.QWidget) -> None: + """Drop the destroyed ``editor`` of row ``name``, unless a newer + editor for the same row has already replaced it.""" + if self.parameters.get(name) is editor: + del self.parameters[name] + def makeParameterWidget( self, item: QtGui.QStandardItem, parent: QtWidgets.QWidget ) -> ParameterWidget: @@ -430,7 +443,10 @@ def setupColumns(self) -> None: @QtCore.Slot(object, object) def onItemNewValue(self, itemName: str, value: Any) -> None: - widget = self.delegate.parameters[itemName] + widget = self.delegate.parameters.get(itemName) + if widget is None: + # the row is hidden, so it has no editor to update + return try: # use the abstract set method defined in parameter widget so it works for different types of widgets widget._setMethod(value) diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index fbfc796..4196d74 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -2220,6 +2220,37 @@ def counting_ask(message): finally: gui.model.stopListener() + +def test_filtering_away_a_locked_row_and_back_does_not_crash( + qtbot, pm, server_port +): + """Qt deletes a row's editor when the filter hides the row, and every + filter change re-applies the Locks to the rows' editors. The delegate + used to keep the deleted editor, so typing a filter that hid a locked + row ("L", then "LO") raised "wrapped C/C++ object of type QPushButton + has been deleted". Hiding the row and bringing it back must raise + nothing, and the returning row's editor must carry its Lock again.""" + _make_live_parameters(pm) + pm.lock("q01.IF", "q02.IF") + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.show() + qtbot.waitExposed(gui) + gui.view.expandAll() + with qtbot.captureExceptions() as exceptions: + for text in ["L", "LO", "L", ""]: + gui.lineEdit.setText(text) + qtbot.wait(50) + assert exceptions == [] + + widget = gui.view.delegate.parameters["q01.IF"] + assert widget.lockButton.isVisibleTo(widget) + assert widget.read_only is True + finally: + gui.model.stopListener() + # --------------------------------------------------------------------------- # plan task 5.5: the Types tab (and the parameter-creation branch fix) # --------------------------------------------------------------------------- From b5d109469a31cd975c866bc5783472c30b9cea81 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 17:03:33 -0500 Subject: [PATCH 103/107] Let the Locks panel's columns be resized The value and buttons columns were fixed and the locks column stretched, so no header edge could be dragged and the Target's value editor was squeezed until its number was unreadable. The locks and buttons columns are now interactive and the value column takes the rest. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../gui/parameter_manager/panels.py | 18 +++-- test/pytest/test_pm_gui.py | 65 ++++++++++++++++++- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/instrumentserver/gui/parameter_manager/panels.py b/src/instrumentserver/gui/parameter_manager/panels.py index 575afaf..1da835d 100644 --- a/src/instrumentserver/gui/parameter_manager/panels.py +++ b/src/instrumentserver/gui/parameter_manager/panels.py @@ -257,9 +257,9 @@ def disarm(self) -> None: "Lock row locks them all again." ) -#: Fixed pixel width of the Locks panel's value column (the mock's value -#: column) and of its buttons column. -LOCK_PANEL_VALUE_WIDTH = 200 +#: Starting pixel widths of the Locks panel's locks and buttons columns; +#: the user can drag both, and the value column takes the rest. +LOCK_PANEL_NAME_WIDTH = 180 LOCK_PANEL_BUTTONS_WIDTH = 84 #: Data role under which a Locks panel row's path (relative to the @@ -331,11 +331,15 @@ def __init__( self.view.setEditTriggers( QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers ) + # the user can drag the locks and buttons columns; the value + # column takes whatever width is left, so the values stay readable header = self.view.header() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) - header.resizeSection(1, LOCK_PANEL_VALUE_WIDTH) - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Fixed) + assert header is not None + header.setStretchLastSection(False) + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.resizeSection(0, LOCK_PANEL_NAME_WIDTH) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Interactive) header.resizeSection(2, LOCK_PANEL_BUTTONS_WIDTH) self.lockSelectionButton = QtWidgets.QPushButton( diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 4196d74..701f0a4 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -48,7 +48,7 @@ import pytest from qcodes.instrument import InstrumentBase -from instrumentserver import QtCore, QtWidgets +from instrumentserver import QtCore, QtGui, QtWidgets from instrumentserver.blueprints import ( PARAMETER_CALL, PARAMETER_UPDATE, @@ -1725,6 +1725,69 @@ def test_the_tree_takes_the_spare_height_and_the_add_strip_sits_at_the_bottom( gui.model.stopListener() +def _drag_header_edge(header, column, dx): + """Drag the right edge of ``column`` on ``header`` by ``dx`` pixels + with the left mouse button, the way a user resizes a column. (Qt's + test mouseMove does not carry a held button, so the events are sent + directly.)""" + edge = header.sectionViewportPosition(column) + header.sectionSize(column) - 1 + y = header.height() // 2 + left = QtCore.Qt.MouseButton.LeftButton + for kind, x, buttons in [ + (QtCore.QEvent.Type.MouseButtonPress, edge, left), + (QtCore.QEvent.Type.MouseMove, edge + dx, left), + (QtCore.QEvent.Type.MouseButtonRelease, edge + dx, QtCore.Qt.MouseButton.NoButton), + ]: + event = QtGui.QMouseEvent( + kind, + QtCore.QPointF(x, y), + left, + buttons, + QtCore.Qt.KeyboardModifier.NoModifier, + ) + QtWidgets.QApplication.sendEvent(header.viewport(), event) + + +def test_the_panel_columns_can_be_resized_and_the_value_fits( + qtbot, pm, server_port +): + """The Locks panel's columns used to be fixed: no header edge could be + dragged, and the Target's value editor was squeezed until its number + was unreadable. Dragging the locks column's edge moves width between + the names and the values, the buttons column can be dragged too, and + the value column gives the editor at least the width it asks for.""" + _make_live_parameters(pm) + pm.lock("q01.IF", "q02.IF") + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.resize(1200, 800) + gui.show() + qtbot.waitExposed(gui) + gui.locksAction.trigger() + header = gui.locksPanel.view.header() + + def _value_fits(): + # re-read the editor: a panel rebuild replaces it + editor = gui.locksPanel.rowWidgets["q02.IF"]["editor"] + return header.sectionSize(1) >= editor.sizeHint().width() + + # the panel lays its columns out once it has been shown + qtbot.waitUntil(_value_fits, timeout=BROADCAST_TIMEOUT) + + name_width, value_width = header.sectionSize(0), header.sectionSize(1) + _drag_header_edge(header, 0, -60) + assert header.sectionSize(0) == name_width - 60 + assert header.sectionSize(1) == value_width + 60 + + buttons_width = header.sectionSize(2) + _drag_header_edge(header, 2, -20) + assert header.sectionSize(2) == buttons_width - 20 + finally: + gui.model.stopListener() + + def test_the_locks_action_toggles_the_panel(qtbot, pm, server_port): """The toolbar action is checkable and unchecked, the panel starts hidden as the splitter's second pane, and the shortcut is registered; From c6f9d23982f8ee9c5c84079871679e5d16e5e67a Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 29 Sep 2026 17:03:33 -0500 Subject: [PATCH 104/107] Keep Qt out of the GUI log handler so the app exits cleanly QLogHandler was a QObject as well as a logging.Handler. Qt deletes it with the log widget, and logging.shutdown then visits it at interpreter exit, printing "wrapped C/C++ object of type QLogHandler has been deleted". The handler is now a plain logging.Handler; the text widget, the signal and the slot live in a _LogBridge parented to the widget. The handler reaches the signal through the bridge on each emit, so a record after the widget is gone raises the RuntimeError the handler already catches (a cached bound signal would segfault instead). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/instrumentserver/log.py | 50 ++++++++++++------- test/pytest/test_log_widget.py | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 test/pytest/test_log_widget.py diff --git a/src/instrumentserver/log.py b/src/instrumentserver/log.py index 9819b13..55bec25 100644 --- a/src/instrumentserver/log.py +++ b/src/instrumentserver/log.py @@ -20,29 +20,18 @@ class LogLevels(Enum): debug = auto() -class QLogHandler(QtCore.QObject, logging.Handler): - """A simple log handler that supports logging in TextEdit""" - - COLORS = { - logging.ERROR: QtGui.QColor("red"), - logging.WARNING: QtGui.QColor("orange"), - logging.INFO: QtGui.QColor("green"), - logging.DEBUG: QtGui.QColor("gray"), - } +class _LogBridge(QtCore.QObject): + """The Qt side of :class:`QLogHandler`: the text widget, and the signal + that carries each html line into the GUI thread, where the slot + appends it to the widget.""" #: Signal(str) : Emitted with the html-formatted log record to append to the widget new_html = QtCore.Signal(str) def __init__(self, parent: Optional[QtWidgets.QWidget]) -> None: - QtCore.QObject.__init__(self, parent) - logging.Handler.__init__(self) - + super().__init__(parent) self.widget = QtWidgets.QTextEdit(parent) self.widget.setReadOnly(True) - self._transform: Optional[Callable[[logging.LogRecord, str], Optional[str]]] = ( - None - ) - # connect signal to slot that actually touches the widget (GUI thread) self.new_html.connect(self._append_html) @@ -57,6 +46,31 @@ def _append_html(self, html: str) -> None: self.widget.verticalScrollBar().maximum() # type: ignore[union-attr] ) + +class QLogHandler(logging.Handler): + """A simple log handler that supports logging in TextEdit. + + The handler itself is a plain :class:`logging.Handler`, not a + ``QObject``: :func:`logging.shutdown` visits every handler at + interpreter exit, after Qt has deleted the widgets, and touching a + deleted ``QObject`` there raises. The Qt objects live in a + :class:`_LogBridge` parented to ``parent``.""" + + COLORS = { + logging.ERROR: QtGui.QColor("red"), + logging.WARNING: QtGui.QColor("orange"), + logging.INFO: QtGui.QColor("green"), + logging.DEBUG: QtGui.QColor("gray"), + } + + def __init__(self, parent: Optional[QtWidgets.QWidget]) -> None: + super().__init__() + self._bridge = _LogBridge(parent) + self.widget = self._bridge.widget + self._transform: Optional[Callable[[logging.LogRecord, str], Optional[str]]] = ( + None + ) + def set_transform( self, fn: Callable[[logging.LogRecord, str], Optional[str]] ) -> None: @@ -89,7 +103,7 @@ def emit(self, record: logging.LogRecord) -> None: ) # send to GUI thread - self.new_html.emit(html) + self._bridge.new_html.emit(html) return # fallback: original plain text path @@ -97,7 +111,7 @@ def emit(self, record: logging.LogRecord) -> None: clr_q = self.COLORS.get(record.levelno, QtGui.QColor("black")).name() html = f"{escape(msg)}" - self.new_html.emit(html) + self._bridge.new_html.emit(html) except RuntimeError: # Widget has been destroyed; detach self from the logger so we # stop receiving further records and Python can collect us. diff --git a/test/pytest/test_log_widget.py b/test/pytest/test_log_widget.py new file mode 100644 index 0000000..05f72fa --- /dev/null +++ b/test/pytest/test_log_widget.py @@ -0,0 +1,89 @@ +"""The GUI log handler: records reach the log widget from any thread, and +nothing it leaves behind raises once Qt has deleted the widget — neither a +later record nor ``logging.shutdown`` at interpreter exit.""" + +import logging +import os +import subprocess +import sys +import textwrap +import threading + +from qtpy import sip + +from instrumentserver.log import LogWidget, QLogHandler + +LOGGER = "instrumentserver" + + +def test_a_record_reaches_the_widget(qtbot): + widget = LogWidget() + qtbot.addWidget(widget) + try: + logging.getLogger(LOGGER).warning("hello from the test") + qtbot.waitUntil( + lambda: "hello from the test" in widget.handler.widget.toPlainText() + ) + finally: + logging.getLogger(LOGGER).removeHandler(widget.handler) + + +def test_a_record_from_another_thread_reaches_the_widget(qtbot): + """The server logs from its own thread; the line must still be + appended (in the GUI thread, through the bridge's signal).""" + widget = LogWidget() + qtbot.addWidget(widget) + try: + thread = threading.Thread( + target=lambda: logging.getLogger(LOGGER).warning("from a thread") + ) + thread.start() + thread.join() + qtbot.waitUntil( + lambda: "from a thread" in widget.handler.widget.toPlainText() + ) + finally: + logging.getLogger(LOGGER).removeHandler(widget.handler) + + +def test_a_record_after_the_widget_is_deleted_detaches_the_handler(qtbot): + widget = LogWidget() + handler = widget.handler + assert isinstance(handler, QLogHandler) + sip.delete(widget) + logger = logging.getLogger(LOGGER) + with qtbot.captureExceptions() as exceptions: + logger.warning("nobody is listening") + assert exceptions == [] + assert handler not in logger.handlers + + +def test_closing_the_app_leaves_no_traceback_from_the_log_handler(): + """Qt deletes the log widget before the interpreter exits, and + ``logging.shutdown`` then visits every handler still registered. The + handler used to be a QObject itself, so the exit printed "wrapped + C/C++ object of type QLogHandler has been deleted".""" + script = textwrap.dedent( + """ + import logging + from qtpy import sip + from instrumentserver import QtWidgets + from instrumentserver.log import LogWidget + + app = QtWidgets.QApplication([]) + widget = LogWidget() + logging.getLogger("instrumentserver").warning("hello") + sip.delete(widget) + """ + ) + env = dict(os.environ, QT_QPA_PLATFORM="offscreen") + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + env=env, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert "Traceback" not in result.stderr, result.stderr + assert "has been deleted" not in result.stderr, result.stderr From 435003c10bc42fd6a106be8f40fd33d19104424e Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 1 Oct 2026 22:39:11 -0500 Subject: [PATCH 105/107] Give the Type tints a dark-theme palette The tree's row tints and gutter bands were light-only and made the theme's light text hard to read on a dark desktop. A second palette with the same five hues is picked when the application palette is dark, and the GUI re-tints when the palette switches. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../gui/parameter_manager/logic.py | 46 +++++++++++-- .../gui/parameter_manager/widget.py | 25 +++++++- test/pytest/test_pm_gui.py | 64 ++++++++++++++++++- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/instrumentserver/gui/parameter_manager/logic.py b/src/instrumentserver/gui/parameter_manager/logic.py index 2633a91..7690ca3 100644 --- a/src/instrumentserver/gui/parameter_manager/logic.py +++ b/src/instrumentserver/gui/parameter_manager/logic.py @@ -39,10 +39,10 @@ "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 1 ) -#: The mock's TINTS, light values only (D21: no dark theme): ``tint`` and -#: ``tintAlt`` are the row background of a claimed row (``tintAlt`` for -#: every other sibling row), ``bar`` the colour of its gutter band. The -#: slot of a Type is its index in this list. +#: The mock's TINTS for a light theme: ``tint`` and ``tintAlt`` are the +#: row background of a claimed row (``tintAlt`` for every other sibling +#: row), ``bar`` the colour of its gutter band. The slot of a Type is its +#: index in this list. TINT_PALETTE: List[Dict[str, str]] = [ {"tint": "#e8f1fb", "tintAlt": "#dfe9f6", "bar": "#4a7fc1"}, {"tint": "#e9f4e9", "tintAlt": "#e0ede0", "bar": "#4f9e57"}, @@ -51,11 +51,42 @@ {"tint": "#e5f4f2", "tintAlt": "#dcece9", "bar": "#3f9490"}, ] -#: The palette as QColors, in the same slot order. +#: The same five hues for a dark theme, slot for slot: dark, low-saturation +#: tints that keep the theme's light text readable, and brighter bars so +#: the gutter bands stand out on a dark background. +TINT_PALETTE_DARK: List[Dict[str, str]] = [ + {"tint": "#1e2a3a", "tintAlt": "#233245", "bar": "#5b8fd1"}, + {"tint": "#1e2e21", "tintAlt": "#243627", "bar": "#5fae67"}, + {"tint": "#33291b", "tintAlt": "#3b3020", "bar": "#c99a4e"}, + {"tint": "#352122", "tintAlt": "#3e2728", "bar": "#c5706f"}, + {"tint": "#1b302e", "tintAlt": "#213835", "bar": "#4fa4a0"}, +] + +#: The palettes as QColors, in the same slot order. TINT_COLOURS: List[Dict[str, QtGui.QColor]] = [ {name: QtGui.QColor(value) for name, value in entry.items()} for entry in TINT_PALETTE ] +TINT_COLOURS_DARK: List[Dict[str, QtGui.QColor]] = [ + {name: QtGui.QColor(value) for name, value in entry.items()} + for entry in TINT_PALETTE_DARK +] + + +def is_dark_theme() -> bool: + """Whether the application currently uses a dark theme: its palette's + window colour is darker than its window text. Reading the palette + (rather than the platform's colour scheme) also covers a dark palette + or style sheet set on the application itself.""" + palette = QtGui.QGuiApplication.palette() + window = palette.color(QtGui.QPalette.ColorRole.Window) + text = palette.color(QtGui.QPalette.ColorRole.WindowText) + return window.lightness() < text.lightness() + + +def tint_colours() -> List[Dict[str, QtGui.QColor]]: + """The tint palette for the current theme (:func:`is_dark_theme`).""" + return TINT_COLOURS_DARK if is_dark_theme() else TINT_COLOURS @dataclass @@ -263,9 +294,10 @@ def sync(self, type_names: Any) -> None: def colours(self, type_name: str) -> Optional[Dict[str, QtGui.QColor]]: """The palette entry of the Type ``type_name`` (``tint``, - ``tintAlt`` and ``bar``), or ``None`` when it has no slot.""" + ``tintAlt`` and ``bar``) for the current theme, or ``None`` when it + has no slot.""" slot = self.slots.get(type_name) - return None if slot is None else TINT_COLOURS[slot] + return None if slot is None else tint_colours()[slot] def bar_colour(self, type_name: str) -> Optional[QtGui.QColor]: """The gutter band colour of the Type ``type_name``.""" diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py index 8c08254..3383d93 100644 --- a/src/instrumentserver/gui/parameter_manager/widget.py +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -49,6 +49,7 @@ build_lock_rows, compute_claims, followers_reaching, + is_dark_theme, lock_button_tooltip, lock_column_text, lock_row_paths, @@ -1281,9 +1282,13 @@ def __init__( # Created before connectSignals, which wires the model's Broadcast # routing into it. self.state = PMState() - # The tint palette: maps each Type to its slot in TINT_PALETTE; the - # view's gutter delegate reads the colours from it. + # The tint palette: maps each Type to its slot in TINT_PALETTE (or + # TINT_PALETTE_DARK in a dark theme); the view's gutter delegate + # reads the colours from it. self.typePalette = TypePalette() + # The theme the tints were last applied for; changeEvent re-tints + # when the application switches between light and dark. + self._darkTheme = is_dark_theme() self.view.gutterDelegate.typePalette = self.typePalette self.profileManager = ProfilesManager(parent=self) self.addParam = AddParameterWidget(parent=self) @@ -1350,6 +1355,22 @@ def __init__( self.connectSignals() self.loadProfile() + def changeEvent(self, event: QtCore.QEvent) -> None: + """Re-tint the tree and the Types pane when the application + switches between a light and a dark theme (the tints are stored + on the rows, so they do not follow the palette on their own).""" + super().changeEvent(event) + if event.type() in ( + QtCore.QEvent.Type.PaletteChange, + QtCore.QEvent.Type.ApplicationPaletteChange, + ): + # the event can arrive while __init__ is still building the GUI + controller = getattr(self, "typesController", None) + dark = is_dark_theme() + if controller is not None and dark != self._darkTheme: + self._darkTheme = dark + controller.apply_tints() + def connectSignals(self) -> None: super().connectSignals() self.view.delegate.removeParameter.connect(self.removeParameter) diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index 701f0a4..fd6fdc6 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -68,6 +68,7 @@ LOCK_COLUMN, LOCK_COLUMN_WIDTH, TINT_COLOURS, + TINT_COLOURS_DARK, Claim, PMState, TypePalette, @@ -76,8 +77,10 @@ compute_claims, followers_reaching, instances_of_type, + is_dark_theme, lock_column_text, lock_root, + tint_colours, parse_default_text, rank_lock_targets, relative_path, @@ -841,7 +844,7 @@ def _type_tint(gui, type_name): slot = gui.typePalette.slots.get(type_name) if slot is None: return None - entry = TINT_COLOURS[slot] + entry = tint_colours()[slot] return (entry["tint"], entry["tintAlt"]) @@ -949,7 +952,7 @@ def test_refresh_all_recomputes_tints_after_a_model_reload( ) gui.refreshAll() - entry = TINT_COLOURS[gui.typePalette.slots["equbit"]] + entry = tint_colours()[gui.typePalette.slots["equbit"]] for item in _row_items(gui, "eq01.IF"): assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in ( entry["tint"], @@ -960,6 +963,63 @@ def test_refresh_all_recomputes_tints_after_a_model_reload( gui.model.stopListener() +def test_tints_follow_a_switch_to_a_dark_theme( + qtbot, pm, second_client, server_port +): + """Switching the application to a dark palette re-tints the claimed + rows with the dark palette entry, and switching back restores the + light one.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + pm.update() + + app = QtWidgets.QApplication.instance() + original = app.palette() + # start from a light palette whatever theme the machine is in + light = QtGui.QPalette(original) + light.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("#f0f0f0")) + light.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("#202020")) + app.setPalette(light) + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", unit="Hz") + gui.refreshAll() + slot = gui.typePalette.slots["dqubit"] + assert not is_dark_theme() + light_entry = TINT_COLOURS[slot] + assert _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) in (light_entry["tint"], light_entry["tintAlt"]) + + dark = QtGui.QPalette(light) + dark.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("#202020")) + dark.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("#f0f0f0")) + app.setPalette(dark) + dark_entry = TINT_COLOURS_DARK[slot] + qtbot.waitUntil( + lambda: _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in (dark_entry["tint"], dark_entry["tintAlt"]), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.typePalette.bar_colour("dqubit") == dark_entry["bar"] + + app.setPalette(light) + qtbot.waitUntil( + lambda: _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in (light_entry["tint"], light_entry["tintAlt"]), + timeout=BROADCAST_TIMEOUT, + ) + finally: + app.setPalette(original) + gui.model.stopListener() + + def test_a_deletion_broadcast_recomputes_the_tints( qtbot, pm, second_client, server_port ): From c3ee72b4baaa6b033289dbb1155812572e6918b0 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 1 Oct 2026 22:39:11 -0500 Subject: [PATCH 106/107] Stop a dead log handler from skipping the next handler When a log widget is gone, its handler detached itself with removeHandler while the logger was looping over that same list, so the handler after it missed the record. It now replaces the list instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/instrumentserver/log.py | 5 ++++- test/pytest/test_log_widget.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/instrumentserver/log.py b/src/instrumentserver/log.py index 55bec25..59375a3 100644 --- a/src/instrumentserver/log.py +++ b/src/instrumentserver/log.py @@ -115,11 +115,14 @@ def emit(self, record: logging.LogRecord) -> None: except RuntimeError: # Widget has been destroyed; detach self from the logger so we # stop receiving further records and Python can collect us. + # Assign a new list rather than removeHandler: the logger is + # iterating over its handlers list right now, and removing from + # it in place would skip the handler after this one. for lg in list(logging.Logger.manager.loggerDict.values()) + [ logging.getLogger() ]: if isinstance(lg, logging.Logger) and self in lg.handlers: - lg.removeHandler(self) + lg.handlers = [h for h in lg.handlers if h is not self] class LogWidget(QtWidgets.QWidget): diff --git a/test/pytest/test_log_widget.py b/test/pytest/test_log_widget.py index 05f72fa..0ab0014 100644 --- a/test/pytest/test_log_widget.py +++ b/test/pytest/test_log_widget.py @@ -58,6 +58,23 @@ def test_a_record_after_the_widget_is_deleted_detaches_the_handler(qtbot): assert handler not in logger.handlers +def test_detaching_a_dead_handler_does_not_skip_the_next_handler(qtbot): + """A dead handler detaches itself while the logger is looping over its + handlers; the handler after it must still get the record.""" + logger = logging.getLogger(LOGGER) + first = LogWidget() + first_handler = first.handler + sip.delete(first) + second = LogWidget() + second_handler = second.handler + sip.delete(second) + with qtbot.captureExceptions() as exceptions: + logger.warning("both dead handlers should detach") + assert exceptions == [] + assert first_handler not in logger.handlers + assert second_handler not in logger.handlers + + def test_closing_the_app_leaves_no_traceback_from_the_log_handler(): """Qt deletes the log widget before the interpreter exits, and ``logging.shutdown`` then visits every handler still registered. The From 5945e914df3e040a709f4ddb1655373924ec4441 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Thu, 1 Oct 2026 23:10:34 -0500 Subject: [PATCH 107/107] Fix the ruff and mypy failures in CI ruff format the files the branch touched and sort one import block. mypy: Qt's stubs return Optional from header(), selectionModel(), invisibleRootItem(), style() and addAction(), so those get asserts; the model's rows are cast to ItemBase where their name and element are read; paint and changeEvent take Optional arguments like their base classes. In params.py the Type Lock loop now keeps only the entries that have a Target, so it carries a str rather than an Optional. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/instrumentserver/base.py | 4 +- .../gui/parameter_manager/logic.py | 22 +- .../gui/parameter_manager/panels.py | 136 ++--- .../gui/parameter_manager/widget.py | 63 +-- src/instrumentserver/params.py | 127 ++--- src/instrumentserver/resource.py | 13 +- test/docs_verification/helpers.py | 4 +- .../technical_guide/verify_broadcasts.py | 27 +- test/pytest/test_apps.py | 16 +- test/pytest/test_broadcaster.py | 8 +- test/pytest/test_client_station.py | 4 +- test/pytest/test_log_widget.py | 4 +- test/pytest/test_param_manager.py | 4 +- test/pytest/test_pm_gui.py | 529 ++++++++++-------- test/pytest/test_pm_locks.py | 13 +- test/pytest/test_pm_persistence.py | 12 +- test/pytest/test_pm_types.py | 43 +- 17 files changed, 487 insertions(+), 542 deletions(-) diff --git a/src/instrumentserver/base.py b/src/instrumentserver/base.py index b66f984..bb2d397 100644 --- a/src/instrumentserver/base.py +++ b/src/instrumentserver/base.py @@ -95,9 +95,7 @@ class Broadcaster: def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self._broadcast_sinks: list[ - Callable[[ParameterBroadcastBluePrint], None] - ] = [] + self._broadcast_sinks: list[Callable[[ParameterBroadcastBluePrint], None]] = [] def add_broadcast_sink( self, fn: "Callable[[ParameterBroadcastBluePrint], None]" diff --git a/src/instrumentserver/gui/parameter_manager/logic.py b/src/instrumentserver/gui/parameter_manager/logic.py index 7690ca3..a16be1a 100644 --- a/src/instrumentserver/gui/parameter_manager/logic.py +++ b/src/instrumentserver/gui/parameter_manager/logic.py @@ -35,9 +35,7 @@ #: Data role under which a row's stack of Type names is stored on its #: gutter item; :class:`.GutterDelegate` reads it to draw the bands. -GUTTER_ROLE = cast( - "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 1 -) +GUTTER_ROLE = cast("QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 1) #: The mock's TINTS for a light theme: ``tint`` and ``tintAlt`` are the #: row background of a claimed row (``tintAlt`` for every other sibling @@ -339,7 +337,7 @@ def relative_path(full: str, instrument_name: str) -> str: stores the full dotted path, while model item names and every string the GUI shows the user are relative to the Parameter Manager.""" prefix = f"{instrument_name}." - return full[len(prefix):] if full.startswith(prefix) else full + return full[len(prefix) :] if full.startswith(prefix) else full def lock_column_text( @@ -430,7 +428,7 @@ def rank_lock_targets( if arm_rel is None: follower_claim = claims.get(follower) arm_rel = ( - follower[len(follower_claim.instance) + 1:] + follower[len(follower_claim.instance) + 1 :] if follower_claim is not None else None ) @@ -439,7 +437,7 @@ def own_rel(candidate: str) -> Optional[str]: claim = claims.get(candidate) if claim is None: return None - return candidate[len(claim.instance) + 1:] + return candidate[len(claim.instance) + 1 :] ranked: List[Tuple[int, str]] = [] for candidate in candidates: @@ -518,9 +516,7 @@ def build_lock_rows( def target_of(follower: str) -> Optional[str]: lock = locks.get(follower) - return ( - None if lock is None else relative_path(lock.target, instrument_name) - ) + return None if lock is None else relative_path(lock.target, instrument_name) targets: List[str] = [] for follower in locks: @@ -541,9 +537,7 @@ def type_locks_at(path: str) -> List[Tuple[str, str]]: return found def followers(path: str) -> List[str]: - return [ - follower for follower in locks if target_of(follower) == path - ] + return [follower for follower in locks if target_of(follower) == path] rows: List[LockRow] = [] seen: set = set() @@ -630,7 +624,7 @@ def _nested_type_at( maps down the segments, the way :func:`_nested_claim_prefixes` walks. ``None`` when no Nested Type is required there — a structural row — or when a nested Type of the chain is missing from ``types``.""" - current = blueprint + current: Optional[PMTypeBluePrint] = blueprint for segment in submodule.split("."): if current is None: return None @@ -699,7 +693,7 @@ def type_entry_rows( target = relative_path(target, instrument_name) else: at = at_by_path.get(path, "") - relative = path[len(at) + 1:] if at else path + relative = path[len(at) + 1 :] if at else path defining = types.get(from_type) default = ( defining.parameters.get(relative, {}).get("default") diff --git a/src/instrumentserver/gui/parameter_manager/panels.py b/src/instrumentserver/gui/parameter_manager/panels.py index 1da835d..c52967d 100644 --- a/src/instrumentserver/gui/parameter_manager/panels.py +++ b/src/instrumentserver/gui/parameter_manager/panels.py @@ -55,18 +55,19 @@ def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: def paint( self, - painter: QtGui.QPainter, + painter: Optional[QtGui.QPainter], option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex, ) -> None: + if painter is None: + return opt = QtWidgets.QStyleOptionViewItem(option) self.initStyleOption(opt, index) opt.text = "" # the background first (alternating row or Type tint), then the bands widget = opt.widget - style = ( - widget.style() if widget is not None else QtWidgets.QApplication.style() - ) + style = widget.style() if widget is not None else QtWidgets.QApplication.style() + assert style is not None # an application always has a style style.drawControl( QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget ) @@ -95,9 +96,7 @@ def sizeHint( option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex, ) -> QtCore.QSize: - return QtCore.QSize( - GUTTER_WIDTH, super().sizeHint(option, index).height() - ) + return QtCore.QSize(GUTTER_WIDTH, super().sizeHint(option, index).height()) # ----------------- Locks -------------------------------------------------------------- @@ -112,12 +111,10 @@ def make_lock_button( Parameter Manager for the state tooltip; the tree's delegate passes ``None`` and leaves the tooltip to :meth:`.LocksController._update_row_lock_widget`.""" - button = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/lock.svg"), "", parent=parent - ) + button = QtWidgets.QPushButton(QtGui.QIcon(":/icons/lock.svg"), "", parent=parent) button.setProperty("locked", locked) button.setStyleSheet( - f"QPushButton[locked=\"true\"] {{ background-color: {LOCK_COLOUR} }}" + f'QPushButton[locked="true"] {{ background-color: {LOCK_COLOUR} }}' ) if target is not None: button.setToolTip(lock_button_tooltip(locked, target)) @@ -164,12 +161,8 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: self.completer = QtWidgets.QCompleter(self) self.completer.setModel(self.completerModel) self.completer.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) - self.completer.setCaseSensitivity( - QtCore.Qt.CaseSensitivity.CaseInsensitive - ) - self.completer.setModelSorting( - QtWidgets.QCompleter.ModelSorting.UnsortedModel - ) + self.completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + self.completer.setModelSorting(QtWidgets.QCompleter.ModelSorting.UnsortedModel) self.lineEdit.setCompleter(self.completer) self.cancelButton = QtWidgets.QPushButton("Cancel", self) @@ -186,7 +179,7 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: layout.addWidget(self.errorLabel) self.setLayout(layout) - self.completer.activated[str].connect(self.targetPicked) # type: ignore[index] + self.completer.activated[str].connect(self.targetPicked) self.lineEdit.returnPressed.connect(self._on_return_pressed) self.cancelButton.clicked.connect(self.cancelled) @@ -210,12 +203,11 @@ def _on_return_pressed(self) -> None: # the completer's filtered matches for what was typed, in ranked # order; its filter mode (MatchContains) and case sensitivity apply self.completer.setCompletionPrefix(text) - if self.completer.completionCount() > 0: - first = self.completer.completionModel().index(0, 0) + completions = self.completer.completionModel() + if completions is not None and self.completer.completionCount() > 0: + first = completions.index(0, 0) self.targetPicked.emit( - self.completer.completionModel().data( - first, QtCore.Qt.ItemDataRole.DisplayRole - ) + completions.data(first, QtCore.Qt.ItemDataRole.DisplayRole) ) def arm(self, follower: str, candidates: List[str]) -> None: @@ -265,9 +257,7 @@ def disarm(self) -> None: #: Data role under which a Locks panel row's path (relative to the #: Parameter Manager) is stored on its first item, so the rows can be #: found again after a rebuild. -LOCK_ROW_ROLE = cast( - "QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 2 -) +LOCK_ROW_ROLE = cast("QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 2) class LocksPanel(QtWidgets.QWidget): @@ -342,9 +332,7 @@ def __init__( header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Interactive) header.resizeSection(2, LOCK_PANEL_BUTTONS_WIDTH) - self.lockSelectionButton = QtWidgets.QPushButton( - "Lock selection to…", self - ) + self.lockSelectionButton = QtWidgets.QPushButton("Lock selection to…", self) self.selectedLabel = QtWidgets.QLabel(self) self.selectedLabel.setText("no parameter selected") @@ -384,7 +372,9 @@ def rebuild( rebuild; no collapsed state is kept.""" self.model.removeRows(0, self.model.rowCount()) self.rowWidgets = {} - self._build_rows(rows, elements, types, locks, self.model.invisibleRootItem()) + root = self.model.invisibleRootItem() + assert root is not None # a model always has its root item + self._build_rows(rows, elements, types, locks, root) self.view.expandAll() def refresh_values(self, paths: Iterable[str]) -> None: @@ -400,8 +390,7 @@ def refresh_values(self, paths: Iterable[str]) -> None: if entry.get("editor") is not None: entry["editor"].setWidgetFromParameter() elif ( - entry.get("label") is not None - and entry.get("element") is not None + entry.get("label") is not None and entry.get("element") is not None ): entry["label"].setText(str(entry["element"].get())) except RuntimeError: @@ -455,9 +444,7 @@ def _build_rows( ) -> None: for row in rows: if row.type_locks: - label = ( - f"[type: {', '.join(t for t, _ in row.type_locks)}] {row.path}" - ) + label = f"[type: {', '.join(t for t, _ in row.type_locks)}] {row.path}" else: label = row.path name_item = QtGui.QStandardItem(label) @@ -473,9 +460,7 @@ def _build_rows( value_item, buttons_item, ) - self._build_rows( - row.children, elements, types, locks, name_item - ) + self._build_rows(row.children, elements, types, locks, name_item) def _build_row_widgets( self, @@ -601,9 +586,7 @@ def _build_row_widgets( entry["toggle"] = toggle entry["remove"] = remove if container is not None: - self.view.setIndexWidget( - self.model.indexFromItem(buttons_item), container - ) + self.view.setIndexWidget(self.model.indexFromItem(buttons_item), container) # ----------------- Types tab ---------------------------------------------------------- @@ -744,6 +727,7 @@ def __init__( ) self.typeList.setAlternatingRowColors(True) typeHeader = self.typeList.header() + assert typeHeader is not None # a QTreeView always has a header typeHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) for column, width in ((1, 70), (2, 90)): typeHeader.setSectionResizeMode( @@ -769,9 +753,7 @@ def __init__( typeListLayout.addWidget(self.typeNote) # -- right: the entries pane above the instances pane - rightPane = QtWidgets.QSplitter( - QtCore.Qt.Orientation.Vertical, self.splitter - ) + rightPane = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self.splitter) entriesPane = QtWidgets.QWidget(rightPane) entriesLayout = QtWidgets.QVBoxLayout(entriesPane) @@ -789,6 +771,7 @@ def __init__( ) self.entriesView.setAlternatingRowColors(True) entriesHeader = self.entriesView.header() + assert entriesHeader is not None # a QTreeView always has a header entriesHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) for column, width in ( (1, ENTRIES_UNIT_WIDTH), @@ -860,6 +843,7 @@ def __init__( ) self.instancesView.setAlternatingRowColors(True) instancesHeader = self.instancesView.header() + assert instancesHeader is not None # a QTreeView always has a header instancesHeader.setSectionResizeMode( 0, QtWidgets.QHeaderView.ResizeMode.Stretch ) @@ -911,7 +895,9 @@ def __init__( self.nestedAtEdit.returnPressed.connect(self.addNestedButton.click) self.addInstanceButton.clicked.connect(self._request_add_instance) self.newInstanceEdit.returnPressed.connect(self.addInstanceButton.click) - self.typeList.selectionModel().currentChanged.connect(self._on_type_selected) + typeSelection = self.typeList.selectionModel() + assert typeSelection is not None # set together with the model + typeSelection.currentChanged.connect(self._on_type_selected) # ------------------------------------------------------------------ # rebuilds (plan task 5.5, readings 2-4, 7-8) @@ -964,9 +950,7 @@ def _rebuild_type_list( colours = palette.colours(name) if colours is not None: for item in (name_item, instances_item, params_item): - item.setData( - colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole - ) + item.setData(colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole) if name == self.selectedType: current_row = row self._building = True @@ -1017,6 +1001,7 @@ def _clear_index_widgets( rebuild does not leave the old ones behind.""" if parent is None: parent = self.entriesModel.invisibleRootItem() + assert parent is not None # a model always has its root item for row in range(parent.rowCount()): for column in range(parent.columnCount()): child = parent.child(row, column) @@ -1031,9 +1016,7 @@ def _clear_index_widgets( if first is not None and first.hasChildren(): self._clear_index_widgets(first) - def _entry_tint_type( - self, rows: List[EntryRow], index: int, selected: str - ) -> str: + def _entry_tint_type(self, rows: List[EntryRow], index: int, selected: str) -> str: """The Type whose tint an entries row shows: an entry row its defining Type, a Nested Type row the Type required there, and a structural submodule row the defining Type of the first entry @@ -1044,7 +1027,7 @@ def _entry_tint_type( return row.from_type or selected if row.nested_type is not None: return row.nested_type - for later in rows[index + 1:]: + for later in rows[index + 1 :]: if later.kind == "entry": return later.from_type or selected return selected @@ -1068,6 +1051,7 @@ def _rebuild_entries( if "." in path else self.entriesModel.invisibleRootItem() ) + assert parent_item is not None # a model always has its root item colours = palette.colours(self._entry_tint_type(rows, index, selected)) name_item = QtGui.QStandardItem(path.split(".")[-1]) unit_item = QtGui.QStandardItem("" if row.kind == "submodule" else row.unit) @@ -1077,9 +1061,7 @@ def _rebuild_entries( items_by_path[path] = name_item if colours is not None: for item in (name_item, unit_item, lock_item, default_item): - item.setData( - colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole - ) + item.setData(colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole) entry: Dict[str, Any] = { "editor": None, "set": None, @@ -1096,9 +1078,7 @@ def _rebuild_entries( selected, blueprint, row, lock_item, default_item, entry ) else: - self._build_entry_row( - selected, row, lock_item, default_item, entry - ) + self._build_entry_row(selected, row, lock_item, default_item, entry) self.entriesView.expandAll() def _build_submodule_row( @@ -1116,7 +1096,9 @@ def _build_submodule_row( # only the selected Type's OWN Nested Types are removable nested = blueprint.nested[row.path] remove = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/delete.svg"), "", parent=self.entriesView.viewport() + QtGui.QIcon(":/icons/delete.svg"), + "", + parent=self.entriesView.viewport(), ) remove.setStyleSheet("QPushButton { background-color: salmon }") remove.setToolTip( @@ -1124,8 +1106,8 @@ def _build_submodule_row( ) keepSmallHorizontally(remove) remove.pressed.connect( - lambda type_name=selected, submodule=row.path: self.removeNestedRequested.emit( - type_name, submodule + lambda type_name=selected, submodule=row.path: ( + self.removeNestedRequested.emit(type_name, submodule) ) ) self.entriesView.setIndexWidget( @@ -1188,8 +1170,8 @@ def _build_own_entry_cells( ) keepSmallHorizontally(retarget) retarget.pressed.connect( - lambda type_name=selected, path=row.path: self.retargetTypeLockRequested.emit( - type_name, path + lambda type_name=selected, path=row.path: ( + self.retargetTypeLockRequested.emit(type_name, path) ) ) lock_layout.addWidget(retarget) @@ -1217,9 +1199,7 @@ def _build_own_entry_cells( ) keepSmallHorizontally(set_button) set_button.pressed.connect( - lambda: self.setDefaultRequested.emit( - selected, row.path, editor.text() - ) + lambda: self.setDefaultRequested.emit(selected, row.path, editor.text()) ) editor.returnPressed.connect(set_button.click) remove = QtWidgets.QPushButton( @@ -1263,9 +1243,7 @@ def _build_nested_entry_cells( ) layout.addWidget(default_label, 1) defined_by = QtWidgets.QLabel(f"defined by {row.from_type}", parent=container) - defined_by.setToolTip( - f"defined by {row.from_type} — change the default there" - ) + defined_by.setToolTip(f"defined by {row.from_type} — change the default there") layout.addWidget(defined_by) self.entriesView.setIndexWidget( self.entriesModel.indexFromItem(default_item), container @@ -1285,9 +1263,7 @@ def _rebuild_instances( return colours = palette.colours(selected) for instance in instances_of_type(selected, types, parameters): - count = sum( - 1 for path in parameters if path.startswith(f"{instance}.") - ) + count = sum(1 for path in parameters if path.startswith(f"{instance}.")) also = [ type_name for type_name in also_types(instance, types, parameters) @@ -1295,25 +1271,19 @@ def _rebuild_instances( ] name_item = QtGui.QStandardItem(instance) count_item = QtGui.QStandardItem(f"{count} parameters") - also_item = QtGui.QStandardItem( - f"also {', '.join(also)}" if also else "" - ) + also_item = QtGui.QStandardItem(f"also {', '.join(also)}" if also else "") button_item = QtGui.QStandardItem() self.instancesModel.appendRow( [name_item, count_item, also_item, button_item] ) if colours is not None: for item in (name_item, count_item, also_item, button_item): - item.setData( - colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole - ) - show = QtWidgets.QPushButton( - "Show", parent=self.instancesView.viewport() - ) + item.setData(colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole) + show = QtWidgets.QPushButton("Show", parent=self.instancesView.viewport()) show.setToolTip("show in the parameter tree") show.pressed.connect( - lambda type_name=selected, node=instance: self.showInstanceRequested.emit( - type_name, node + lambda type_name=selected, node=instance: ( + self.showInstanceRequested.emit(type_name, node) ) ) self.instancesView.setIndexWidget( diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py index 3383d93..f2f4b5c 100644 --- a/src/instrumentserver/gui/parameter_manager/widget.py +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -30,6 +30,7 @@ paramTypeFromName, ) from .. import keepSmallHorizontally +from ..base_instrument import ItemBase from ..instruments import ( InstrumentParameters, ModelParameters, @@ -294,10 +295,10 @@ def _collect_parameters( self, parent: QtGui.QStandardItem, parameters: Dict[str, str] ) -> None: for row in range(parent.rowCount()): - item = parent.child(row, 0) + item = cast(Optional[ItemBase], parent.child(row, 0)) if item is None: continue - if item.element is not None: # type: ignore[attr-defined] + if item.element is not None: # a parameter row; a submodule row's element is None unitItem = parent.child(row, 1) parameters[item.name] = "" if unitItem is None else unitItem.text() @@ -549,17 +550,16 @@ def connectSignals(self) -> None: gui.armStrip.cancelled.connect(self.cancel_arm) # the Locks panel (plan task 5.4): the tree's current row drives # the panel's selected label + assert gui.locksAction is not None # created by the GUI's toolbar gui.locksAction.toggled.connect(self._on_locks_action_toggled) gui.locksPanel.toggleLockRequested.connect(self._on_panel_toggle_lock) gui.locksPanel.removeLockRequested.connect(self._on_panel_remove_lock) gui.locksPanel.lockAllRequested.connect(self._on_panel_lock_all) gui.locksPanel.removeRuleRequested.connect(self._on_panel_remove_rule) - gui.locksPanel.lockSelectionRequested.connect( - self._lock_selection_from_panel - ) - gui.view.selectionModel().currentChanged.connect( - self._on_tree_current_changed - ) + gui.locksPanel.lockSelectionRequested.connect(self._lock_selection_from_panel) + treeSelection = gui.view.selectionModel() + assert treeSelection is not None # set together with the model + treeSelection.currentChanged.connect(self._on_tree_current_changed) # the Types tab's Type Lock re-target arms the same strip gui.typesPane.retargetTypeLockRequested.connect(self.arm_type_lock) gui.shortcutManager.register("toggle_locks", gui.locksAction.toggle, gui) @@ -571,9 +571,7 @@ def connectSignals(self) -> None: gui.shortcutManager.register_tooltip("unlock_item", gui.view.unlockAction) @QtCore.Slot(str, object) - def _on_lock_changed( - self, path: str, lock: Optional[PMLockBluePrint] - ) -> None: + def _on_lock_changed(self, path: str, lock: Optional[PMLockBluePrint]) -> None: """Record the change a ``pm-lock-update`` Broadcast reports about the Follower at ``path``, then recompute the Lock column and the row widgets, and repaint the values the change alters: the @@ -649,7 +647,7 @@ def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: """Walk the source model (never the proxy) and set each row's Lock column text, lock button state and read-only flag.""" for row in range(parent.rowCount()): - item = parent.child(row, 0) + item = cast(Optional[ItemBase], parent.child(row, 0)) if item is None: continue lockItem = parent.child(row, LOCK_COLUMN) @@ -670,16 +668,12 @@ def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: if item.hasChildren(): self._apply_locks_to_rows(item) - def _update_row_lock_widget( - self, path: str, widget: "ParameterWidget" - ) -> None: + def _update_row_lock_widget(self, path: str, widget: "ParameterWidget") -> None: """Set one row's lock button and read-only state from the Lock the state holds for ``path``. A row without a Lock shows no button and renders its value editable.""" button = ( - widget.lockButton - if isinstance(widget, LockableParameterWidget) - else None + widget.lockButton if isinstance(widget, LockableParameterWidget) else None ) lock = self.gui.state.locks.get(path) if lock is None: @@ -756,8 +750,8 @@ def _update_lock_actions(self) -> None: self.gui.view.lockToAction.setEnabled(is_parameter) self.gui.view.unlockAction.setEnabled( is_parameter - and item.name in self.gui.state.locks # type: ignore[union-attr] - and self.gui.state.locks[item.name].locked # type: ignore[union-attr] + and item.name in self.gui.state.locks + and self.gui.state.locks[item.name].locked ) def arm_lock(self, follower: str) -> None: @@ -989,9 +983,7 @@ def connectSignals(self) -> None: gui.typesPane.addEntryRequested.connect(self._on_pane_add_entry) gui.typesPane.removeEntryRequested.connect(self._on_pane_remove_entry) gui.typesPane.setDefaultRequested.connect(self._on_pane_set_default) - gui.typesPane.toggleTypeLockRequested.connect( - self._on_pane_toggle_type_lock - ) + gui.typesPane.toggleTypeLockRequested.connect(self._on_pane_toggle_type_lock) gui.typesPane.addNestedRequested.connect(self._on_pane_add_nested) gui.typesPane.removeNestedRequested.connect(self._on_pane_remove_nested) gui.typesPane.addInstanceRequested.connect(self._on_pane_add_instance) @@ -1033,7 +1025,7 @@ def _apply_tints_to_rows( item; clear the background of the rows without one.""" for row in range(parent.rowCount()): rowItems = [parent.child(row, col) for col in range(LOCK_COLUMN + 1)] - item = rowItems[0] + item = cast(Optional[ItemBase], rowItems[0]) if item is None: continue gutterItem = rowItems[GUTTER_COLUMN] @@ -1178,9 +1170,7 @@ def _on_pane_toggle_type_lock(self, type_name: str, path: str) -> None: self.refresh_types_pane() @QtCore.Slot(str, str, str) - def _on_pane_add_nested( - self, type_name: str, submodule: str, nested: str - ) -> None: + def _on_pane_add_nested(self, type_name: str, submodule: str, nested: str) -> None: """The "Nested type" strip: require the Nested Type ``nested`` at the submodule (D11, D13). A refused edit shows the Server's error text on the entries pane's note.""" @@ -1303,9 +1293,7 @@ def __init__( self.locksPanel = LocksPanel(self.instrument.name, parent=self) view_index = layout.indexOf(self.view) layout.removeWidget(self.view) - self.locksSplitter = QtWidgets.QSplitter( - QtCore.Qt.Orientation.Horizontal, self - ) + self.locksSplitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal, self) self.locksSplitter.addWidget(self.view) self.locksSplitter.addWidget(self.locksPanel) self.locksSplitter.setStretchFactor(0, 3) @@ -1355,12 +1343,12 @@ def __init__( self.connectSignals() self.loadProfile() - def changeEvent(self, event: QtCore.QEvent) -> None: + def changeEvent(self, event: Optional[QtCore.QEvent]) -> None: """Re-tint the tree and the Types pane when the application switches between a light and a dark theme (the tints are stored on the rows, so they do not follow the palette on their own).""" super().changeEvent(event) - if event.type() in ( + if event is not None and event.type() in ( QtCore.QEvent.Type.PaletteChange, QtCore.QEvent.Type.ApplicationPaletteChange, ): @@ -1420,6 +1408,7 @@ def makeToolbar(self) -> QtWidgets.QToolBar: QtGui.QIcon(":/icons/lock.svg"), "Show the Locks panel", ) + assert self.locksAction is not None # addAction always returns one self.locksAction.setCheckable(True) self.shortcutManager.register_tooltip("toggle_locks", self.locksAction) @@ -1470,12 +1459,14 @@ def removeParameter(self, fullName: str) -> None: # reads back empty there); tests pin the dialog through its # object name and text instead. box.setText( - f"Removing {fullName} also removes the Locks of:\n" - + "\n".join(lines) + f"Removing {fullName} also removes the Locks of:\n" + "\n".join(lines) ) box.setStandardButtons( - QtWidgets.QMessageBox.StandardButton.Ok - | QtWidgets.QMessageBox.StandardButton.Cancel + cast( + "QtWidgets.QMessageBox.StandardButtons", + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel, + ) ) box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) self.removalDialog = box diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 37c4137..36ab13a 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -6,7 +6,7 @@ from enum import Enum, auto, unique from functools import wraps from pathlib import Path -from typing import Any, Callable, Dict, Iterator, List, Tuple, Union +from typing import Any, Callable, Dict, Iterator, List, Tuple, Union, cast from qcodes import Parameter, validators from qcodes.instrument import InstrumentBase @@ -708,7 +708,7 @@ def _param_by_full_path(self, full_path: str) -> ParameterBase | None: if not full_path.startswith(prefix): return None try: - return self._get_param(full_path[len(prefix):]) + return self._get_param(full_path[len(prefix) :]) except ValueError: return None @@ -802,9 +802,7 @@ def unlock(self, name: str) -> None: param = self._resolve_param(name) lock = self._require_lock(param, name) if not lock.locked: - logger.info( - f"{self._full_path(name)} is already unlocked; nothing to do" - ) + logger.info(f"{self._full_path(name)} is already unlocked; nothing to do") return lock.locked = False # a snapshot, not the live record: sinks must not see the payload @@ -831,8 +829,7 @@ def relock(self, name: str) -> None: target_param = self._param_by_full_path(lock.target) if target_param is None: raise ValueError( - f"{follower_full} remembers Target {lock.target}, " - "which does not exist" + f"{follower_full} remembers Target {lock.target}, which does not exist" ) self._check_lock_allowed(follower_full, lock.target) assert isinstance(param, ManagedParameter) @@ -1114,7 +1111,7 @@ def _nested_cycle( def walk(defn: _TypeDefinition, chain: List[str]) -> List[str] | None: for nested_name in defn.nested.values(): if nested_name in chain: - return chain[chain.index(nested_name):] + [nested_name] + return chain[chain.index(nested_name) :] + [nested_name] nested = types.get(nested_name) if nested is None: raise ValueError( @@ -1177,6 +1174,7 @@ def _iter_submodule_groups(self) -> Iterator[Tuple[str, "ParameterGroup"]]: Group below this Parameter Manager, at any depth: never the root itself, and never the reserved Globals submodule ``_globals`` or anything inside it (D12).""" + def walk( group: "ParameterGroup", prefix: str ) -> Iterator[Tuple[str, "ParameterGroup"]]: @@ -1272,7 +1270,7 @@ def types_of(self, path: str) -> List[str]: prefix = f"{submodule_path}." if not path.startswith(prefix): continue - if path[len(prefix):] not in effective: + if path[len(prefix) :] not in effective: continue depth = len(submodule_path) size = len(effective) @@ -1339,9 +1337,7 @@ def walk(name: str, prefix: str, branch: Tuple[str, ...]) -> None: walk(type_name, "", (type_name,)) return prefixes - def _check_creation_targets( - self, targets: List[Tuple[str, str]] - ) -> None: + def _check_creation_targets(self, targets: List[Tuple[str, str]]) -> None: """Validate the parameters a Type edit or :meth:`add_instance` is about to create, before anything is mutated. ``targets`` holds ``(Instance path, relative target path)`` pairs. An intermediate @@ -1386,11 +1382,10 @@ def _check_creation_targets( group = None break walked.append(segment) - group = submodule + group = cast(ParameterGroup, submodule) if blocked is not None: offending[full] = ( - f"'{blocked}' is a parameter, and cannot have " - "child parameters" + f"'{blocked}' is a parameter, and cannot have child parameters" ) continue if group is None: @@ -1400,7 +1395,7 @@ def _check_creation_targets( last = index == len(segments) - 1 if segment in group.parameters: if not last: - blocked = f"{instance_path}.{'.'.join(segments[:index + 1])}" + blocked = f"{instance_path}.{'.'.join(segments[: index + 1])}" offending[full] = ( f"'{blocked}' is a parameter, and cannot have " "child parameters" @@ -1408,9 +1403,7 @@ def _check_creation_targets( break if last: if segment in group.submodules: - offending[full] = ( - f"'{full}' is already a Parameter Group" - ) + offending[full] = f"'{full}' is already a Parameter Group" break submodule = group.submodules.get(segment) if submodule is None: @@ -1423,7 +1416,7 @@ def _check_creation_targets( # edit, whatever Instance or nesting chain produced them full_paths = sorted(seen) for index, shorter in enumerate(full_paths): - for longer in full_paths[index + 1:]: + for longer in full_paths[index + 1 :]: if longer.startswith(f"{shorter}."): blocked, blocker = longer, shorter elif shorter.startswith(f"{longer}."): @@ -1463,7 +1456,9 @@ def _require_type_entry(self, type_name: str, path: str) -> _TypeEntry: f"parameter path '{path}' is not an entry of Type '{type_name}'" ) - def _instances_before_edit(self, affected: Dict[str, List[str]]) -> Dict[str, List[str]]: + def _instances_before_edit( + self, affected: Dict[str, List[str]] + ) -> Dict[str, List[str]]: """The Instances of every Type in ``affected``, computed while the registry still holds the shape the edit is about to change (D13): after the edit no submodule matches until it carries what is new, @@ -1516,9 +1511,7 @@ def add_type_parameter( # or the tree is touched definition = self._require_type(type_name) if not path or any(segment == "" for segment in path.split(".")): - raise ValueError( - f"'{path}' is not a valid parameter path for a Type entry" - ) + raise ValueError(f"'{path}' is not a valid parameter path for a Type entry") expanded = self._expand_effective(type_name) if path in expanded: from_type = expanded[path][1] @@ -1539,10 +1532,7 @@ def add_type_parameter( for prefix in affected[name]: candidate = f"{prefix}{path}" if candidate in current: - described = ( - f"'{candidate}' (in the effective set of " - f"Type '{name}')" - ) + described = f"'{candidate}' (in the effective set of Type '{name}')" if described not in collisions: collisions.append(described) if collisions: @@ -1607,9 +1597,7 @@ def remove_type_parameter(self, type_name: str, path: str) -> None: for name in affected: self._broadcast_type_update(name) - def set_type_parameter_default( - self, type_name: str, path: str, value: Any - ) -> None: + def set_type_parameter_default(self, type_name: str, path: str, value: Any) -> None: """Set the default value of the Type ``type_name``'s own entry at ``path`` (D13): the parameters the Instances already carry keep their values, and only parameters created later start with the @@ -1631,9 +1619,7 @@ def set_type_parameter_default( entry.default = value self._broadcast_type_update(type_name) - def set_type_parameter_unit( - self, type_name: str, path: str, unit: str - ) -> None: + def set_type_parameter_unit(self, type_name: str, path: str, unit: str) -> None: """Set the unit of the Type ``type_name``'s own entry at ``path`` and propagate it to that parameter in every Instance of the Type and of every Type whose effective parameter set contains @@ -1667,13 +1653,11 @@ def set_type_parameter_unit( continue propagated.add(full) if self.has_param(full): - self.parameter(full).unit = unit + cast(Parameter, self.parameter(full)).unit = unit for name in affected: self._broadcast_type_update(name) - def add_nested_type( - self, type_name: str, submodule: str, nested_type: str - ) -> None: + def add_nested_type(self, type_name: str, submodule: str, nested_type: str) -> None: """Require the Nested Type ``nested_type`` at the submodule ``submodule`` of the Type ``type_name`` (D11), and write the nested Type's effective parameter set under that submodule into @@ -1725,8 +1709,7 @@ def add_nested_type( definition = self._types[type_name] if not submodule or any(segment == "" for segment in submodule.split(".")): raise ValueError( - f"'{submodule}' is not a valid submodule name for a " - "Nested Type" + f"'{submodule}' is not a valid submodule name for a Nested Type" ) if submodule.split(".")[0] == "_globals": raise ValueError( @@ -1777,8 +1760,7 @@ def add_nested_type( new_path = f"{prefix}{submodule}.{entry_path}" if new_path in current or new_path in new_paths: described = ( - f"'{new_path}' (in the effective set of " - f"Type '{name}')" + f"'{new_path}' (in the effective set of Type '{name}')" ) if described not in collisions: collisions.append(described) @@ -1798,7 +1780,10 @@ def add_nested_type( for entry_path, entry in nested_entries.items() ] self._check_creation_targets( - [(instance_path, relative_target) for instance_path, relative_target, _ in targets] + [ + (instance_path, relative_target) + for instance_path, relative_target, _ in targets + ] ) definition.nested[submodule] = nested_type created: set = set() @@ -1809,9 +1794,7 @@ def add_nested_type( continue created.add(full) if not self.has_param(full): - self.add_parameter( - full, initial_value=entry.default, unit=entry.unit - ) + self.add_parameter(full, initial_value=entry.default, unit=entry.unit) creations.append((full, entry.default, entry.unit)) # broadcasts after the whole edit succeeded (D22): one # parameter-creation per created parameter in creation order, @@ -1847,8 +1830,7 @@ def remove_nested_type(self, type_name: str, submodule: str) -> None: definition = self._require_type(type_name) if submodule not in definition.nested: raise ValueError( - f"submodule '{submodule}' of Type '{type_name}' has no " - "Nested Type" + f"submodule '{submodule}' of Type '{type_name}' has no Nested Type" ) affected = self._nesting_prefixes(type_name) del definition.nested[submodule] @@ -1923,9 +1905,7 @@ def add_instance(self, type_name: str, name: str) -> None: # touched self._require_type(type_name) if not name or any(segment == "" for segment in name.split(".")): - raise ValueError( - f"'{name}' is not a valid submodule path for an Instance" - ) + raise ValueError(f"'{name}' is not a valid submodule path for an Instance") if name.split(".")[0] == "_globals": raise ValueError( f"'{name}' is not a valid submodule path for an Instance: " @@ -1964,9 +1944,7 @@ def add_instance(self, type_name: str, name: str) -> None: for path, entry in effective.items(): full = f"{name}.{path}" if not self.has_param(full): - self.add_parameter( - full, initial_value=entry.default, unit=entry.unit - ) + self.add_parameter(full, initial_value=entry.default, unit=entry.unit) creations.append((full, entry.default, entry.unit)) # one parameter-creation per created parameter, in creation order, # then the Type Locks of the new Instances (each emitting its @@ -2168,10 +2146,9 @@ def lock_type_parameter( offenders: List[str] = [] for instance_path in self.instances_of(type_name): param_path = f"{instance_path}.{path}" - action, locked_to = self._classify_lock_application( - param_path, target_full - ) + action, locked_to = self._classify_lock_application(param_path, target_full) if action == "skip": + assert locked_to is not None # "skip" always names the Target skipped.append((param_path, locked_to)) continue if action == "none": @@ -2294,38 +2271,37 @@ def _apply_type_locks_to_new_instances( skipped: List[Tuple[str, str, str, str]] = [] seen: set = set() for type_name in lock_types: - locked_entries = { - entry_path: entry + locked_targets = { + entry_path: entry.target for entry_path, entry in self._effective_entries(type_name).items() if entry.target is not None } - if not locked_entries: + if not locked_targets: continue for instance_path in self.instances_of(type_name): if instance_path in instances_before.get(type_name, []): # an Instance before the edit: only lock_type_parameter # re-applies a Type Lock to everyone continue - for entry_path, entry in locked_entries.items(): + for entry_path, target in locked_targets.items(): param_path = f"{instance_path}.{entry_path}" if param_path in seen: # the same defining entry reaches the parameter # through several Types of the closure continue seen.add(param_path) - if self._param_by_full_path(entry.target) is None: + if self._param_by_full_path(target) is None: skipped.append( ( type_name, entry_path, param_path, - f"the stored Target {entry.target} does " - "not exist", + f"the stored Target {target} does not exist", ) ) continue action, locked_to = self._classify_lock_application( - param_path, entry.target + param_path, target ) if action == "skip": skipped.append( @@ -2333,8 +2309,7 @@ def _apply_type_locks_to_new_instances( type_name, entry_path, param_path, - "it carries a Lock on another Target " - f"({locked_to})", + f"it carries a Lock on another Target ({locked_to})", ) ) continue @@ -2353,18 +2328,16 @@ def _apply_type_locks_to_new_instances( ) continue try: - self._check_lock_allowed(follower_full, entry.target) + self._check_lock_allowed(follower_full, target) except ValueError as exc: - skipped.append( - (type_name, entry_path, param_path, str(exc)) - ) + skipped.append((type_name, entry_path, param_path, str(exc))) continue applications.append( ( type_name, entry_path, param_path, - entry.target[len(self.name) + 1:], + target[len(self.name) + 1 :], action == "relock", ) ) @@ -2389,8 +2362,7 @@ def _apply_type_locks_to_new_instances( skipped.append((type_name, entry_path, param_path, str(exc))) if skipped: described = "; ".join( - f"'{param_path}' (entry '{entry_path}' of Type " - f"'{type_name}') {reason}" + f"'{param_path}' (entry '{entry_path}' of Type '{type_name}') {reason}" for type_name, entry_path, param_path, reason in skipped ) logger.warning( @@ -2763,9 +2735,7 @@ def closure_complete(name: str) -> bool: # follows each Target's Lock within the document regardless of # locked/unlocked state (D7) document_locks = { - key: entry["lock"] - for key, entry in parameters.items() - if "lock" in entry + key: entry["lock"] for key, entry in parameters.items() if "lock" in entry } for follower_full, lock in document_locks.items(): target_full = lock["target"] @@ -2900,8 +2870,7 @@ def _load_v2_document(self, document: Dict[str, Any], deleteMissing: bool) -> No self._check_lock_allowed(follower_full, target_full) target_param = self._param_by_full_path(target_full) assert target_param is not None, ( - "the validated Target is not a parameter of this " - "Parameter Manager" + "the validated Target is not a parameter of this Parameter Manager" ) param._target = target_param param.lock = PMLockBluePrint( diff --git a/src/instrumentserver/resource.py b/src/instrumentserver/resource.py index 90c1e85..775214d 100644 --- a/src/instrumentserver/resource.py +++ b/src/instrumentserver/resource.py @@ -1896,7 +1896,7 @@ \x00\x00\x01\xa0\xac\x0a\x1d\x63\ " -qt_version = [int(v) for v in QtCore.qVersion().split('.')] +qt_version = [int(v) for v in QtCore.qVersion().split(".")] if qt_version < [5, 8, 0]: rcc_version = 1 qt_resource_struct = qt_resource_struct_v1 @@ -1904,10 +1904,17 @@ rcc_version = 2 qt_resource_struct = qt_resource_struct_v2 + def qInitResources(): - QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + def qCleanupResources(): - QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + qInitResources() diff --git a/test/docs_verification/helpers.py b/test/docs_verification/helpers.py index c698ed5..53be235 100644 --- a/test/docs_verification/helpers.py +++ b/test/docs_verification/helpers.py @@ -244,9 +244,7 @@ def _collect() -> None: parts = sock.recv_multipart() except zmq.Again: continue - capture.frames.append( - (parts[0].decode("utf-8"), parts[1].decode("utf-8")) - ) + capture.frames.append((parts[0].decode("utf-8"), parts[1].decode("utf-8"))) thread = threading.Thread(target=_collect, daemon=True) thread.start() diff --git a/test/docs_verification/technical_guide/verify_broadcasts.py b/test/docs_verification/technical_guide/verify_broadcasts.py index f40ada1..23bcf7b 100644 --- a/test/docs_verification/technical_guide/verify_broadcasts.py +++ b/test/docs_verification/technical_guide/verify_broadcasts.py @@ -76,7 +76,9 @@ def workspace(): created under this script's folder and removed again on exit. """ old = os.getcwd() - path = Path(tempfile.mkdtemp(prefix="verify_broadcasts_", dir=Path(__file__).parent)) + path = Path( + tempfile.mkdtemp(prefix="verify_broadcasts_", dir=Path(__file__).parent) + ) os.chdir(path) try: yield path @@ -217,9 +219,9 @@ def recording_sink(bp) -> None: cap.wait_for(1) assert recorded, "the sink never ran" assert recorded[0] is not threading.current_thread() - assert recorded[0].name.startswith("ThreadPoolExecutor"), ( - recorded[0].name - ) + assert recorded[0].name.startswith("ThreadPoolExecutor"), recorded[ + 0 + ].name finally: pm_instrument.remove_broadcast_sink(recording_sink) pm.unlock("q01.IF") @@ -229,9 +231,7 @@ def recording_sink(bp) -> None: # waits until the first one is done def blocked_call() -> None: with client() as second_cli: - finished.append( - second_cli.call(f"{PM_NAME}.set", "q01.IF", 12e6) - ) + finished.append(second_cli.call(f"{PM_NAME}.set", "q01.IF", 12e6)) mutex = srv._get_lock_for_target(PM_NAME) finished = [] @@ -539,9 +539,7 @@ def section_the_broadcaster_contract() -> None: # the mixin itself, no Server involved with workspace(): bc = Broadcaster() - bp = ParameterBroadcastBluePrint( - "bcaster.param0", PARAMETER_UPDATE, 1.0, "V" - ) + bp = ParameterBroadcastBluePrint("bcaster.param0", PARAMETER_UPDATE, 1.0, "V") # no sinks: a no-op bc.broadcast(bp) @@ -570,6 +568,7 @@ def section_the_broadcaster_contract() -> None: logger = logging.getLogger("instrumentserver.base") logger.addHandler(handler) try: + def failing_sink(bp): raise RuntimeError("sink is broken") @@ -753,9 +752,7 @@ def section_the_parameter_managers_actions() -> None: assert [bp.action for bp in messages] == [ PARAMETER_CREATION, PM_TYPE_UPDATE, - ], [ - (bp.action, bp.name, bp.value, bp.unit) for bp in messages - ] + ], [(bp.action, bp.name, bp.value, bp.unit) for bp in messages] assert messages[0].name == "parameter_manager.q02.window" assert messages[0].value == 0.5 assert messages[0].unit == "s" @@ -956,9 +953,7 @@ def section_the_parameter_managers_actions() -> None: "gain": {"default": "12", "unit": "dB", "target": "None"} }, "nested": {}, - "effective": { - "gain": {"unit": "dB", "from_type": "display"} - }, + "effective": {"gain": {"unit": "dB", "from_type": "display"}}, "_class_type": "PMTypeBluePrint", }, "unit": "", diff --git a/test/pytest/test_apps.py b/test/pytest/test_apps.py index 844de02..a22c112 100644 --- a/test/pytest/test_apps.py +++ b/test/pytest/test_apps.py @@ -292,7 +292,9 @@ def test_client_station_script_no_config(): clientStationScript() - mock_cs.assert_called_once_with(host="localhost", port=DEFAULT_PORT, config_path=None) + mock_cs.assert_called_once_with( + host="localhost", port=DEFAULT_PORT, config_path=None + ) def test_client_station_script_with_config(tmp_path): @@ -309,7 +311,9 @@ def test_client_station_script_with_config(tmp_path): clientStationScript() - mock_cs.assert_called_once_with(host="localhost", port=DEFAULT_PORT, config_path=cfg) + mock_cs.assert_called_once_with( + host="localhost", port=DEFAULT_PORT, config_path=cfg + ) def test_detached_server_script_defaults(): @@ -377,9 +381,7 @@ def test_param_manager_script_instrument_exists(): mock_cli.get_instrument.assert_called_once_with("parameter_manager") mock_cli.find_or_create_instrument.assert_not_called() - mock_pmg.assert_called_once_with( - mock_pm, sub_port=4568, sub_host="localhost" - ) + mock_pmg.assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost") mock_wmw.assert_called_once() @@ -412,9 +414,7 @@ def test_param_manager_script_instrument_missing(): mock_cli.get_instrument.assert_not_called() mock_pm.fromFile.assert_called_once() mock_pm.update.assert_called_once() - mock_pmg.assert_called_once_with( - mock_pm, sub_port=4568, sub_host="localhost" - ) + mock_pmg.assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost") mock_wmw.assert_called_once() diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py index f3237af..53b2e91 100644 --- a/test/pytest/test_broadcaster.py +++ b/test/pytest/test_broadcaster.py @@ -34,9 +34,7 @@ def make_bp( value: float = 1.0, unit: str = "Hz", ) -> ParameterBroadcastBluePrint: - return ParameterBroadcastBluePrint( - name=name, action=action, value=value, unit=unit - ) + return ParameterBroadcastBluePrint(name=name, action=action, value=value, unit=unit) # --------------------------------------------------------------------------- @@ -214,7 +212,9 @@ def test_created_broadcaster_instrument_reaches_subclient( assert bp.unit == "V" -def test_plain_dummy_instrument_still_works_and_gets_no_sink(dummy_instrument, start_server): +def test_plain_dummy_instrument_still_works_and_gets_no_sink( + dummy_instrument, start_server +): """A plain dummy instrument keeps working over the wire, and since it does not implement the Broadcaster contract the Server registers no sink.""" cli, dummy = dummy_instrument diff --git a/test/pytest/test_client_station.py b/test/pytest/test_client_station.py index e110e7d..ffaf24e 100644 --- a/test/pytest/test_client_station.py +++ b/test/pytest/test_client_station.py @@ -102,7 +102,9 @@ def test_client_station_gui_has_three_tabs(qtbot, start_server, server_port): station.disconnect() -def test_client_station_gui_server_widget_shows_host_port(qtbot, start_server, server_port): +def test_client_station_gui_server_widget_shows_host_port( + qtbot, start_server, server_port +): from instrumentserver.client.application import ClientStationGui station = ClientStation(host="localhost", port=server_port) diff --git a/test/pytest/test_log_widget.py b/test/pytest/test_log_widget.py index 0ab0014..c59fa97 100644 --- a/test/pytest/test_log_widget.py +++ b/test/pytest/test_log_widget.py @@ -39,9 +39,7 @@ def test_a_record_from_another_thread_reaches_the_widget(qtbot): ) thread.start() thread.join() - qtbot.waitUntil( - lambda: "from a thread" in widget.handler.widget.toPlainText() - ) + qtbot.waitUntil(lambda: "from a thread" in widget.handler.widget.toPlainText()) finally: logging.getLogger(LOGGER).removeHandler(widget.handler) diff --git a/test/pytest/test_param_manager.py b/test/pytest/test_param_manager.py index cdd2a6f..e455f69 100644 --- a/test/pytest/test_param_manager.py +++ b/test/pytest/test_param_manager.py @@ -265,9 +265,7 @@ def test_submodule_does_not_load_parameter_file(tmp_path, monkeypatch): is not loaded into the q01 submodule.""" monkeypatch.chdir(tmp_path) profile = tmp_path / "parameter_manager-q01.json" - profile.write_text( - json.dumps({"q01.file_param": {"value": 999, "unit": "V"}}) - ) + profile.write_text(json.dumps({"q01.file_param": {"value": 999, "unit": "V"}})) params = ParameterManager(name="params") params.add_parameter(name="q01.my_param", initial_value=1, unit="M") diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py index fd6fdc6..8d67fd7 100644 --- a/test/pytest/test_pm_gui.py +++ b/test/pytest/test_pm_gui.py @@ -80,10 +80,10 @@ is_dark_theme, lock_column_text, lock_root, - tint_colours, parse_default_text, rank_lock_targets, relative_path, + tint_colours, type_entry_rows, ) from instrumentserver.gui.parameter_manager.panels import ( @@ -195,15 +195,11 @@ def _wait_until_broadcasts_arrive(qtbot, gui, second_pm): name = f"gui_probe_type_{attempt}" second_pm.add_type(name) try: - qtbot.waitUntil( - lambda: name in gui.state.types, timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: name in gui.state.types, timeout=BROADCAST_TIMEOUT) except Exception: continue # the probe Broadcast was lost to the slow joiner second_pm.remove_type(name) - qtbot.waitUntil( - lambda: name not in gui.state.types, timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: name not in gui.state.types, timeout=BROADCAST_TIMEOUT) return raise AssertionError( "the GUI's listener received no Broadcast; cannot test live updates" @@ -362,8 +358,10 @@ def test_state_on_construction_holds_types_and_locks_created_before( timeout=BROADCAST_TIMEOUT, ) qtbot.waitUntil( - lambda: gui.state.locks.get("cq02.x") - == PMLockBluePrint(target=f"{PM_NAME}.cq01.x", locked=True), + lambda: ( + gui.state.locks.get("cq02.x") + == PMLockBluePrint(target=f"{PM_NAME}.cq01.x", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) assert gui.state.types["cqubit"].parameters["IF"] == { @@ -390,15 +388,19 @@ def test_lock_broadcasts_from_a_second_client_update_the_state( second_pm.lock("q02.x", "q01.x") qtbot.waitUntil( - lambda: gui.state.locks.get("q02.x") - == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=True), + lambda: ( + gui.state.locks.get("q02.x") + == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) second_pm.unlock("q02.x") qtbot.waitUntil( - lambda: gui.state.locks.get("q02.x") - == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=False), + lambda: ( + gui.state.locks.get("q02.x") + == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=False) + ), timeout=BROADCAST_TIMEOUT, ) @@ -430,8 +432,10 @@ def test_type_broadcasts_from_a_second_client_update_the_state( second_pm.add_type_parameter("qubit", "IF", unit="Hz") qtbot.waitUntil( - lambda: gui.state.types.get("qubit") is not None - and "IF" in gui.state.types["qubit"].parameters, + lambda: ( + gui.state.types.get("qubit") is not None + and "IF" in gui.state.types["qubit"].parameters + ), timeout=BROADCAST_TIMEOUT, ) assert gui.state.types["qubit"].parameters["IF"] == { @@ -493,15 +497,19 @@ def test_type_lock_from_a_second_client_updates_types_and_locks( second_pm.lock_type_parameter("dqubit", "IF", target="tshared") qtbot.waitUntil( - lambda: gui.state.types.get("dqubit") is not None - and gui.state.types["dqubit"].parameters["IF"]["target"] - == f"{PM_NAME}.tshared", + lambda: ( + gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared" + ), timeout=BROADCAST_TIMEOUT, ) for follower in ("dq01.IF", "dq02.IF"): qtbot.waitUntil( - lambda follower=follower: gui.state.locks.get(follower) - == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True), + lambda follower=follower: ( + gui.state.locks.get(follower) + == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -527,9 +535,7 @@ def test_a_second_clients_set_reaches_the_tree_widget( assert line_edit.text() == "1.0" second_pm.sq01.x.set(42) - qtbot.waitUntil( - lambda: line_edit.text() == "42", timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: line_edit.text() == "42", timeout=BROADCAST_TIMEOUT) finally: gui.model.stopListener() @@ -565,7 +571,9 @@ def test_refresh_all_refills_the_state_from_the_server( # --------------------------------------------------------------------------- -def _type_blueprint(name, entries, nested=None, registry=None, defaults=None, targets=None): +def _type_blueprint( + name, entries, nested=None, registry=None, defaults=None, targets=None +): """A ``PMTypeBluePrint`` whose effective set is expanded the way ``params.py`` expands it: the Type's own entries carry itself as ``from_type``, and every Nested Type's effective set is mounted under @@ -661,7 +669,10 @@ def test_compute_claims_innermost_nested_type_wins(): the ``qubit`` behind it in the stack.""" readout = _type_blueprint("readout", {"bw": "Hz"}) qubit = _type_blueprint( - "qubit", {"IF": "Hz"}, nested={"readout": "readout"}, registry={"readout": readout} + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, ) claims = compute_claims( {"readout": readout, "qubit": qubit}, @@ -690,7 +701,10 @@ def test_compute_claims_of_a_nested_type_nested_two_levels_deep(): registry={"pulse_window": pulse_window}, ) qubit = _type_blueprint( - "qubit", {"IF": "Hz"}, nested={"readout": "readout"}, registry={"readout": readout} + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, ) claims = compute_claims( {"pulse_window": pulse_window, "readout": readout, "qubit": qubit}, @@ -801,9 +815,7 @@ def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): header = gui.view.header() assert header.visualIndex(GUTTER_COLUMN) == 0 assert header.sectionSize(GUTTER_COLUMN) == GUTTER_WIDTH - assert isinstance( - gui.view.itemDelegateForColumn(GUTTER_COLUMN), GutterDelegate - ) + assert isinstance(gui.view.itemDelegateForColumn(GUTTER_COLUMN), GutterDelegate) assert gui.view.gutterDelegate.typePalette is gui.typePalette assert gui.view.treePosition() == 0 # the Lock column sits between the unit and the delegate column @@ -869,11 +881,13 @@ def test_tints_follow_a_second_clients_type(qtbot, pm, second_client, server_por second_pm.add_type_parameter("qubit", "IF", unit="Hz") qtbot.waitUntil( - lambda: _type_tint(gui, "qubit") is not None - and _row_items(gui, "q01.IF")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in _type_tint(gui, "qubit"), + lambda: ( + _type_tint(gui, "qubit") is not None + and _row_items(gui, "q01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit") + ), timeout=BROADCAST_TIMEOUT, ) tint = _type_tint(gui, "qubit") @@ -891,10 +905,12 @@ def test_tints_follow_a_second_clients_type(qtbot, pm, second_client, server_por # q01 already carries readout.bw: no creation, and the row tints too second_pm.add_type_parameter("qubit", "readout.bw", unit="Hz") qtbot.waitUntil( - lambda: _row_items(gui, "q01.readout.bw")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in tint, + lambda: ( + _row_items(gui, "q01.readout.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in tint + ), timeout=BROADCAST_TIMEOUT, ) for item in _row_items(gui, "q01.readout.bw"): @@ -906,22 +922,24 @@ def test_tints_follow_a_second_clients_type(qtbot, pm, second_client, server_por second_pm.remove_type_parameter("qubit", "IF") second_pm.remove_type_parameter("qubit", "readout.bw") qtbot.waitUntil( - lambda: _row_items(gui, "q01.IF")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - is None - and _row_items(gui, "q01.IF")[3].data(GUTTER_ROLE) == [], + lambda: ( + _row_items(gui, "q01.IF")[0].data(QtCore.Qt.ItemDataRole.BackgroundRole) + is None + and _row_items(gui, "q01.IF")[3].data(GUTTER_ROLE) == [] + ), timeout=BROADCAST_TIMEOUT, ) # removing the Type clears the last tint and frees the palette slot second_pm.remove_type("qubit") qtbot.waitUntil( - lambda: _row_items(gui, "q01.readout.bw")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - is None - and "qubit" not in gui.typePalette.slots, + lambda: ( + _row_items(gui, "q01.readout.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + is None + and "qubit" not in gui.typePalette.slots + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -945,9 +963,7 @@ def test_refresh_all_recomputes_tints_after_a_model_reload( second_pm.add_type("equbit") second_pm.add_type_parameter("equbit", "IF", unit="Hz") assert ( - _row_items(gui, "eq01.IF")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) + _row_items(gui, "eq01.IF")[0].data(QtCore.Qt.ItemDataRole.BackgroundRole) is None ) @@ -963,9 +979,7 @@ def test_refresh_all_recomputes_tints_after_a_model_reload( gui.model.stopListener() -def test_tints_follow_a_switch_to_a_dark_theme( - qtbot, pm, second_client, server_port -): +def test_tints_follow_a_switch_to_a_dark_theme(qtbot, pm, second_client, server_port): """Switching the application to a dark palette re-tints the claimed rows with the dark palette entry, and switching back restores the light one.""" @@ -999,20 +1013,24 @@ def test_tints_follow_a_switch_to_a_dark_theme( app.setPalette(dark) dark_entry = TINT_COLOURS_DARK[slot] qtbot.waitUntil( - lambda: _row_items(gui, "dq01.IF")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in (dark_entry["tint"], dark_entry["tintAlt"]), + lambda: ( + _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in (dark_entry["tint"], dark_entry["tintAlt"]) + ), timeout=BROADCAST_TIMEOUT, ) assert gui.typePalette.bar_colour("dqubit") == dark_entry["bar"] app.setPalette(light) qtbot.waitUntil( - lambda: _row_items(gui, "dq01.IF")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in (light_entry["tint"], light_entry["tintAlt"]), + lambda: ( + _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in (light_entry["tint"], light_entry["tintAlt"]) + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -1045,11 +1063,13 @@ def test_a_deletion_broadcast_recomputes_the_tints( second_pm.add_type_parameter("qubit", "bw", unit="Hz") qtbot.waitUntil( - lambda: _type_tint(gui, "qubit") is not None - and _row_items(gui, "q01.bw")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in _type_tint(gui, "qubit"), + lambda: ( + _type_tint(gui, "qubit") is not None + and _row_items(gui, "q01.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit") + ), timeout=BROADCAST_TIMEOUT, ) tint = _type_tint(gui, "qubit") @@ -1063,8 +1083,7 @@ def test_a_deletion_broadcast_recomputes_the_tints( def _q01_bw_gone_and_q01_untinted(): matches = gui.model.findItems( "q01.bw", - QtCore.Qt.MatchFlag.MatchExactly - | QtCore.Qt.MatchFlag.MatchRecursive, + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, 0, ) if matches: @@ -1078,9 +1097,7 @@ def _q01_bw_gone_and_q01_untinted(): and items[3].data(GUTTER_ROLE) == [] ) - qtbot.waitUntil( - _q01_bw_gone_and_q01_untinted, timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(_q01_bw_gone_and_q01_untinted, timeout=BROADCAST_TIMEOUT) for item in _row_items(gui, "q01"): assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None finally: @@ -1181,9 +1198,7 @@ def test_rank_lock_targets_orders_like_the_mock(): def test_rank_lock_targets_without_a_claim_is_alphabetical(): """A Follower claimed by no Type has no relative path to prefer, so every candidate is rank 2 and sorts alphabetically.""" - ranked = rank_lock_targets( - "q01.IF", ["zz.x", "aa.x", "q01.IF"], {} - ) + ranked = rank_lock_targets("q01.IF", ["zz.x", "aa.x", "q01.IF"], {}) assert ranked == ["aa.x", "zz.x"] @@ -1326,8 +1341,10 @@ def test_arm_via_context_menu_pick_a_row_and_toggle( # clicking the q02.IF row picks it as the Target _click_row(qtbot, gui, "q02.IF") qtbot.waitUntil( - lambda: pm.get_lock("q01.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + lambda: ( + pm.get_lock("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) assert gui.armStrip.isHidden() @@ -1419,8 +1436,10 @@ def test_a_cycle_attempt_shows_the_error_and_stays_armed( pm.lock("q01.IF", "q02.IF") qtbot.waitUntil( - lambda: gui.state.locks.get("q01.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) @@ -1476,10 +1495,12 @@ def test_setting_the_target_from_a_second_client_repaints_the_followers( pm.lock("q01.IF", "q02.IF") pm.lock("q03.IF", "q01.IF") qtbot.waitUntil( - lambda: gui.state.locks.get("q01.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) - and gui.state.locks.get("q03.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + and gui.state.locks.get("q03.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) @@ -1616,8 +1637,7 @@ def test_a_filter_cycle_re_applies_the_lock_state( def _q01_if_is_mapped(mapped: bool): matches = gui.model.findItems( "q01.IF", - QtCore.Qt.MatchFlag.MatchExactly - | QtCore.Qt.MatchFlag.MatchRecursive, + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, 0, ) proxy_index = gui.proxyModel.mapFromSource( @@ -1633,8 +1653,10 @@ def _q01_if_is_mapped(mapped: bool): gui.lineEdit.setText("") widget = gui.view.delegate.parameters["q01.IF"] qtbot.waitUntil( - lambda: widget.lockButton.property("locked") is True - and not widget.lockButton.isHidden(), + lambda: ( + widget.lockButton.property("locked") is True + and not widget.lockButton.isHidden() + ), timeout=BROADCAST_TIMEOUT, ) assert not widget.paramWidget.isEnabled() @@ -1677,9 +1699,7 @@ def test_build_lock_rows_walks_a_chain_nested_and_once(): assert [row.path for row in rows] == ["q01.IF"] root = rows[0] assert [child.path for child in root.children] == ["q02.IF"] - assert [ - grandchild.path for grandchild in root.children[0].children - ] == ["q03.IF"] + assert [grandchild.path for grandchild in root.children[0].children] == ["q03.IF"] def test_build_lock_rows_sorts_the_type_lock_target_first(): @@ -1737,9 +1757,7 @@ def walk(parent): if item is None: continue if item.data(LOCK_ROW_ROLE) == path: - matches.append( - [parent.child(row, column) for column in range(3)] - ) + matches.append([parent.child(row, column) for column in range(3)]) walk(item) walk(gui.locksPanel.model.invisibleRootItem()) @@ -1796,7 +1814,11 @@ def _drag_header_edge(header, column, dx): for kind, x, buttons in [ (QtCore.QEvent.Type.MouseButtonPress, edge, left), (QtCore.QEvent.Type.MouseMove, edge + dx, left), - (QtCore.QEvent.Type.MouseButtonRelease, edge + dx, QtCore.Qt.MouseButton.NoButton), + ( + QtCore.QEvent.Type.MouseButtonRelease, + edge + dx, + QtCore.Qt.MouseButton.NoButton, + ), ]: event = QtGui.QMouseEvent( kind, @@ -1808,9 +1830,7 @@ def _drag_header_edge(header, column, dx): QtWidgets.QApplication.sendEvent(header.viewport(), event) -def test_the_panel_columns_can_be_resized_and_the_value_fits( - qtbot, pm, server_port -): +def test_the_panel_columns_can_be_resized_and_the_value_fits(qtbot, pm, server_port): """The Locks panel's columns used to be fixed: no header edge could be dragged, and the Target's value editor was squeezed until its number was unreadable. Dragging the locks column's edge moves width between @@ -1977,9 +1997,11 @@ def test_the_type_lock_rows_lock_all_and_remove_rule( # parameter-creation Broadcast hits the model's creation branch second_pm.lock_type_parameter("dqubit", "IF", target="tshared") qtbot.waitUntil( - lambda: gui.state.types.get("dqubit") is not None - and gui.state.types["dqubit"].parameters["IF"]["target"] - == f"{PM_NAME}.tshared", + lambda: ( + gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared" + ), timeout=BROADCAST_TIMEOUT, ) @@ -2009,8 +2031,10 @@ def test_the_type_lock_rows_lock_all_and_remove_rule( second_pm.lock_type_parameter("dqubit", "IF", target="tshared") second_pm.unlock("dq01.IF") qtbot.waitUntil( - lambda: pm.get_lock("dq01.IF") is not None - and pm.get_lock("dq01.IF").locked is False, + lambda: ( + pm.get_lock("dq01.IF") is not None + and pm.get_lock("dq01.IF").locked is False + ), timeout=BROADCAST_TIMEOUT, ) qtbot.waitUntil( @@ -2031,15 +2055,17 @@ def test_the_type_lock_rows_lock_all_and_remove_rule( # ... and "lock all" locks it again through the stored Target gui.locksPanel.rowWidgets["tshared"]["lockAll"].click() qtbot.waitUntil( - lambda: pm.get_lock("dq01.IF") is not None - and pm.get_lock("dq01.IF").locked, + lambda: ( + pm.get_lock("dq01.IF") is not None and pm.get_lock("dq01.IF").locked + ), timeout=BROADCAST_TIMEOUT, ) # "lock all" passes the entry's stored Target: a call without it # would re-point the rule to the Globals default (D17) qtbot.waitUntil( - lambda: pm.get_type("dqubit").parameters["IF"]["target"] - == f"{PM_NAME}.tshared", + lambda: ( + pm.get_type("dqubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared" + ), timeout=BROADCAST_TIMEOUT, ) assert pm.get_lock("dq01.IF").target == f"{PM_NAME}.tshared" @@ -2051,7 +2077,7 @@ def test_the_type_lock_rows_lock_all_and_remove_rule( def test_the_lock_all_note_names_the_skipped_followers( qtbot, pm, second_client, server_port ): - """"lock all" names the Instance parameters it skips on the note + """ "lock all" names the Instance parameters it skips on the note label and leaves them locked to their own Target (D17): one whose Lock the second Client re-targeted keeps that Target.""" second_pm = _second_parameter_manager(second_client) @@ -2072,9 +2098,11 @@ def test_the_lock_all_note_names_the_skipped_followers( # parameter-creation Broadcast hits the model's creation branch second_pm.lock_type_parameter("dqubit", "IF", target="tshared") qtbot.waitUntil( - lambda: gui.state.types.get("dqubit") is not None - and gui.state.types["dqubit"].parameters["IF"]["target"] - == f"{PM_NAME}.tshared", + lambda: ( + gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared" + ), timeout=BROADCAST_TIMEOUT, ) @@ -2089,8 +2117,10 @@ def test_the_lock_all_note_names_the_skipped_followers( # buttons has run before the click second_pm.lock("dq01.IF", "talt") qtbot.waitUntil( - lambda: pm.get_lock("dq01.IF") is not None - and pm.get_lock("dq01.IF").target == f"{PM_NAME}.talt", + lambda: ( + pm.get_lock("dq01.IF") is not None + and pm.get_lock("dq01.IF").target == f"{PM_NAME}.talt" + ), timeout=BROADCAST_TIMEOUT, ) qtbot.waitUntil( @@ -2120,9 +2150,7 @@ def test_the_lock_all_note_names_the_skipped_followers( gui.model.stopListener() -def test_the_panel_value_editor_sets_the_target( - qtbot, pm, second_client, server_port -): +def test_the_panel_value_editor_sets_the_target(qtbot, pm, second_client, server_port): """Typing a value into the root Target's editor and pressing its set button sets the parameter on the Server, and the tree's Follower row repaints to it (5.3's repaint path). A second Client's set repaints @@ -2139,8 +2167,10 @@ def test_the_panel_value_editor_sets_the_target( second_pm.lock("q01.IF", "q02.IF") qtbot.waitUntil( - lambda: gui.state.locks.get("q01.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) @@ -2154,9 +2184,7 @@ def test_the_panel_value_editor_sets_the_target( editor.paramWidget.input.setText("11") editor.setButton.click() - qtbot.waitUntil( - lambda: pm.q02.IF.get() == 11, timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: pm.q02.IF.get() == 11, timeout=BROADCAST_TIMEOUT) tree_widget = gui.view.delegate.parameters["q01.IF"] qtbot.waitUntil( lambda: tree_widget._getMethod() == 11, timeout=BROADCAST_TIMEOUT @@ -2170,8 +2198,7 @@ def test_the_panel_value_editor_sets_the_target( lambda: ( gui.locksPanel.rowWidgets.get("q02.IF") is not None and gui.locksPanel.rowWidgets["q02.IF"]["editor"] is not None - and gui.locksPanel.rowWidgets["q02.IF"]["editor"]._getMethod() - == 21 + and gui.locksPanel.rowWidgets["q02.IF"]["editor"]._getMethod() == 21 ), timeout=BROADCAST_TIMEOUT, ) @@ -2188,7 +2215,7 @@ def test_the_panel_value_editor_sets_the_target( def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_port): - """"Lock selection to…" shows the tree's current parameter in the + """ "Lock selection to…" shows the tree's current parameter in the selected label and arms the pick for it; on a submodule row it says so on the note label and arms nothing.""" second_pm = _second_parameter_manager(second_client) @@ -2223,8 +2250,7 @@ def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_po ) gui.locksPanel.lockSelectionButton.click() assert ( - "Select a parameter in the tree first." - in gui.locksPanel.noteLabel.text() + "Select a parameter in the tree first." in gui.locksPanel.noteLabel.text() ) assert gui.locksController.armed_follower is None assert gui.armStrip.isHidden() @@ -2292,7 +2318,6 @@ def _unlocked_toggle_back(): gui.model.stopListener() - def test_an_open_panel_asks_the_server_nothing_while_idle( qtbot, pm, second_client, server_port ): @@ -2332,8 +2357,7 @@ def counting_ask(message): second_pm.update() # it was made before the parameters existed second_pm.q02.IF(7.5) qtbot.waitUntil( - lambda: gui.locksPanel.rowWidgets["q03.IF"]["label"].text() - == "7.5", + lambda: gui.locksPanel.rowWidgets["q03.IF"]["label"].text() == "7.5", timeout=BROADCAST_TIMEOUT, ) assert gui.locksPanel.rowWidgets["q01.IF"]["label"].text() == "7.5" @@ -2344,9 +2368,7 @@ def counting_ask(message): gui.model.stopListener() -def test_filtering_away_a_locked_row_and_back_does_not_crash( - qtbot, pm, server_port -): +def test_filtering_away_a_locked_row_and_back_does_not_crash(qtbot, pm, server_port): """Qt deletes a row's editor when the filter hides the row, and every filter change re-applies the Locks to the rows' editors. The delegate used to keep the deleted editor, so typing a filter that hid a locked @@ -2374,6 +2396,7 @@ def test_filtering_away_a_locked_row_and_back_does_not_crash( finally: gui.model.stopListener() + # --------------------------------------------------------------------------- # plan task 5.5: the Types tab (and the parameter-creation branch fix) # --------------------------------------------------------------------------- @@ -2448,12 +2471,12 @@ def _create_type_with_instance(qtbot, gui, pm, type_name="qubit", instance="q10" gui.tabs.setCurrentIndex(1) gui.typesPane.newTypeEdit.setText(type_name) gui.typesPane.addTypeButton.click() + qtbot.waitUntil(lambda: type_name in pm.list_types(), timeout=BROADCAST_TIMEOUT) qtbot.waitUntil( - lambda: type_name in pm.list_types(), timeout=BROADCAST_TIMEOUT - ) - qtbot.waitUntil( - lambda: gui.typesPane.selectedType == type_name - and _type_list_row(gui, type_name) is not None, + lambda: ( + gui.typesPane.selectedType == type_name + and _type_list_row(gui, type_name) is not None + ), timeout=BROADCAST_TIMEOUT, ) gui.typesPane.entryNameEdit.setText("IF") @@ -2469,9 +2492,7 @@ def _create_type_with_instance(qtbot, gui, pm, type_name="qubit", instance="q10" ) gui.typesPane.newInstanceEdit.setText(instance) gui.typesPane.addInstanceButton.click() - qtbot.waitUntil( - lambda: pm.has_param(f"{instance}.IF"), timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: pm.has_param(f"{instance}.IF"), timeout=BROADCAST_TIMEOUT) qtbot.waitUntil( lambda: _instance_row_items(gui, instance) is not None, timeout=BROADCAST_TIMEOUT, @@ -2492,9 +2513,7 @@ def test_type_entry_rows_build_the_segment_sorted_tree(): defaults={"IF": 1.0}, targets={"IF": f"{PM_NAME}._globals.qubit.IF"}, ) - rows = type_entry_rows( - "qubit", {"readout": readout, "qubit": qubit}, PM_NAME - ) + rows = type_entry_rows("qubit", {"readout": readout, "qubit": qubit}, PM_NAME) assert [row.path for row in rows] == ["IF", "readout", "readout.bw"] assert rows[0].kind == "entry" and rows[0].own assert rows[0].from_type == "qubit" @@ -2595,9 +2614,9 @@ def test_instances_of_type_with_a_nested_type(): parameters = {"q10.IF": "Hz", "q10.readout.bw": "Hz"} assert instances_of_type("qubit", types, parameters) == ["q10"] # the nested entry's unit is off: no Instance - assert instances_of_type( - "qubit", types, {"q10.IF": "Hz", "q10.readout.bw": "V"} - ) == [] + assert ( + instances_of_type("qubit", types, {"q10.IF": "Hz", "q10.readout.bw": "V"}) == [] + ) # the nested entry is missing: no Instance assert instances_of_type("qubit", types, {"q10.IF": "Hz"}) == [] @@ -2657,8 +2676,9 @@ def test_a_creation_from_a_second_client_under_an_existing_submodule_appears( second_pm.add_parameter("cr01.y", initial_value=2.0, unit="Hz") qtbot.waitUntil( - lambda: _row_exists(gui, "cr01.y") - and "cr01.y" in gui.view.delegate.parameters, + lambda: ( + _row_exists(gui, "cr01.y") and "cr01.y" in gui.view.delegate.parameters + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -2679,9 +2699,11 @@ def test_a_creation_from_a_second_client_in_a_new_submodule_appears( second_pm.add_parameter("crnew.z", initial_value=3.0, unit="s") qtbot.waitUntil( - lambda: _row_exists(gui, "crnew") - and _row_exists(gui, "crnew.z") - and "crnew.z" in gui.view.delegate.parameters, + lambda: ( + _row_exists(gui, "crnew") + and _row_exists(gui, "crnew.z") + and "crnew.z" in gui.view.delegate.parameters + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -2710,22 +2732,26 @@ def test_an_add_instance_from_a_second_client_appears_and_tints( second_pm.add_instance("insttype", "instq") qtbot.waitUntil( - lambda: _row_exists(gui, "instq.ix") - and _row_exists(gui, "instq.iy") - and "instq.ix" in gui.view.delegate.parameters - and "instq.iy" in gui.view.delegate.parameters, + lambda: ( + _row_exists(gui, "instq.ix") + and _row_exists(gui, "instq.iy") + and "instq.ix" in gui.view.delegate.parameters + and "instq.iy" in gui.view.delegate.parameters + ), timeout=BROADCAST_TIMEOUT, ) qtbot.waitUntil( - lambda: _type_tint(gui, "insttype") is not None - and _row_items(gui, "instq.ix")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in _type_tint(gui, "insttype") - and _row_items(gui, "instq.iy")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in _type_tint(gui, "insttype"), + lambda: ( + _type_tint(gui, "insttype") is not None + and _row_items(gui, "instq.ix")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "insttype") + and _row_items(gui, "instq.iy")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "insttype") + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -2754,19 +2780,23 @@ def test_an_add_instance_from_the_gui_proxy_appears( pm.add_instance("owntype", "ownq") qtbot.waitUntil( - lambda: _row_exists(gui, "ownq.ox") - and _row_exists(gui, "ownq.oy") - and "ownq.ox" in gui.view.delegate.parameters, + lambda: ( + _row_exists(gui, "ownq.ox") + and _row_exists(gui, "ownq.oy") + and "ownq.ox" in gui.view.delegate.parameters + ), timeout=BROADCAST_TIMEOUT, ) assert pm.ownq.ox.get() == 1.0 assert pm.ownq.oy.get() == 2.0 qtbot.waitUntil( - lambda: _type_tint(gui, "owntype") is not None - and _row_items(gui, "ownq.ox")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in _type_tint(gui, "owntype"), + lambda: ( + _type_tint(gui, "owntype") is not None + and _row_items(gui, "ownq.ox")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "owntype") + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -2799,12 +2829,12 @@ def test_the_types_tab_creates_a_type_and_an_instance( gui.tabs.setCurrentIndex(1) gui.typesPane.newTypeEdit.setText("qubit") gui.typesPane.addTypeButton.click() + qtbot.waitUntil(lambda: "qubit" in pm.list_types(), timeout=BROADCAST_TIMEOUT) qtbot.waitUntil( - lambda: "qubit" in pm.list_types(), timeout=BROADCAST_TIMEOUT - ) - qtbot.waitUntil( - lambda: gui.typesPane.selectedType == "qubit" - and _type_list_row(gui, "qubit") is not None, + lambda: ( + gui.typesPane.selectedType == "qubit" + and _type_list_row(gui, "qubit") is not None + ), timeout=BROADCAST_TIMEOUT, ) # with the Type selected the strips are enabled and the labels @@ -2824,8 +2854,7 @@ def test_the_types_tab_creates_a_type_and_an_instance( lambda: _state_type_has(gui, "qubit", "IF"), timeout=BROADCAST_TIMEOUT ) qtbot.waitUntil( - lambda: gui.typesPane.entryWidgets.get("IF", {}).get("editor") - is not None, + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("editor") is not None, timeout=BROADCAST_TIMEOUT, ) # the type list counts: one effective parameter, no Instances yet @@ -2852,9 +2881,7 @@ def test_the_types_tab_creates_a_type_and_an_instance( # create the Instance through the widgets gui.typesPane.newInstanceEdit.setText("q10") gui.typesPane.addInstanceButton.click() - qtbot.waitUntil( - lambda: pm.has_param("q10.IF"), timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: pm.has_param("q10.IF"), timeout=BROADCAST_TIMEOUT) qtbot.waitUntil( lambda: _instance_row_items(gui, "q10") is not None, timeout=BROADCAST_TIMEOUT, @@ -2872,11 +2899,13 @@ def test_the_types_tab_creates_a_type_and_an_instance( assert _instance_row_items(gui, "q10")[1].text() == "1 parameters" # the Parameters tree shows the q10.IF row tinted with qubit's colour qtbot.waitUntil( - lambda: _type_tint(gui, "qubit") is not None - and _row_items(gui, "q10.IF")[0].data( - QtCore.Qt.ItemDataRole.BackgroundRole - ) - in _type_tint(gui, "qubit"), + lambda: ( + _type_tint(gui, "qubit") is not None + and _row_items(gui, "q10.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit") + ), timeout=BROADCAST_TIMEOUT, ) @@ -2884,9 +2913,11 @@ def test_the_types_tab_creates_a_type_and_an_instance( # effective parameter count follows it second_pm.add_type_parameter("qubit", "bw", default=2.0, unit="Hz") qtbot.waitUntil( - lambda: _entry_row_items(gui, "bw") is not None - and _row_exists(gui, "q10.bw") - and _type_list_row(gui, "qubit")[2].text() == "2", + lambda: ( + _entry_row_items(gui, "bw") is not None + and _row_exists(gui, "q10.bw") + and _type_list_row(gui, "qubit")[2].text() == "2" + ), timeout=BROADCAST_TIMEOUT, ) finally: @@ -2925,8 +2956,7 @@ def test_the_types_tab_edits_entries_and_nested_types( gui.typesPane.entryUnitEdit.setText("Hz ") gui.typesPane.addEntryButton.click() qtbot.waitUntil( - lambda: gui.typesPane.entryWidgets.get("bw", {}).get("remove") - is not None, + lambda: gui.typesPane.entryWidgets.get("bw", {}).get("remove") is not None, timeout=BROADCAST_TIMEOUT, ) assert pm.get_type("qubit").parameters["bw"]["default"] == 2.0 @@ -2976,8 +3006,10 @@ def test_the_types_tab_edits_entries_and_nested_types( # the entries pane shows the submodule row with its Nested Type # and the defined-by entry qtbot.waitUntil( - lambda: _entry_row_items(gui, "readout") is not None - and _entry_row_items(gui, "readout.bw") is not None, + lambda: ( + _entry_row_items(gui, "readout") is not None + and _entry_row_items(gui, "readout.bw") is not None + ), timeout=BROADCAST_TIMEOUT, ) assert _entry_row_items(gui, "readout")[2].text() == "type: readout" @@ -3023,13 +3055,17 @@ def _state_target(): # toggle on: the Globals default Target is created and locked gui.typesPane.entryWidgets["IF"]["toggle"].click() qtbot.waitUntil( - lambda: _state_target() == globals_target - and pm.get_type("qubit").parameters["IF"]["target"] == globals_target, + lambda: ( + _state_target() == globals_target + and pm.get_type("qubit").parameters["IF"]["target"] == globals_target + ), timeout=BROADCAST_TIMEOUT, ) qtbot.waitUntil( - lambda: pm.get_lock("q10.IF") - == PMLockBluePrint(target=globals_target, locked=True), + lambda: ( + pm.get_lock("q10.IF") + == PMLockBluePrint(target=globals_target, locked=True) + ), timeout=BROADCAST_TIMEOUT, ) # the _globals.qubit.IF row appears in the tree @@ -3043,8 +3079,10 @@ def _state_target(): # the one still in flight gui.typesPane.entryWidgets["IF"]["toggle"].click() qtbot.waitUntil( - lambda: _state_target() is None - and pm.get_type("qubit").parameters["IF"]["target"] is None, + lambda: ( + _state_target() is None + and pm.get_type("qubit").parameters["IF"]["target"] is None + ), timeout=BROADCAST_TIMEOUT, ) assert pm.get_lock("q10.IF") is not None @@ -3052,8 +3090,10 @@ def _state_target(): # toggle on once more: the re-target button needs a locked entry gui.typesPane.entryWidgets["IF"]["toggle"].click() qtbot.waitUntil( - lambda: _state_target() == globals_target - and pm.get_type("qubit").parameters["IF"]["target"] == globals_target, + lambda: ( + _state_target() == globals_target + and pm.get_type("qubit").parameters["IF"]["target"] == globals_target + ), timeout=BROADCAST_TIMEOUT, ) @@ -3062,8 +3102,9 @@ def _state_target(): pm.add_parameter("tshared", initial_value=0.0, unit="Hz") qtbot.waitUntil(lambda: _row_exists(gui, "tshared"), timeout=BROADCAST_TIMEOUT) qtbot.waitUntil( - lambda: gui.typesPane.entryWidgets.get("IF", {}).get("retarget") - is not None, + lambda: ( + gui.typesPane.entryWidgets.get("IF", {}).get("retarget") is not None + ), timeout=BROADCAST_TIMEOUT, ) gui.typesPane.entryWidgets["IF"]["retarget"].click() @@ -3087,8 +3128,9 @@ def _state_target(): gui.locksController.pick_lock_target("tshared") qtbot.waitUntil( - lambda: pm.get_type("qubit").parameters["IF"]["target"] - == f"{PM_NAME}.tshared", + lambda: ( + pm.get_type("qubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared" + ), timeout=BROADCAST_TIMEOUT, ) assert gui.armStrip.isHidden() @@ -3115,13 +3157,14 @@ def test_the_types_tab_names_skipped_locks_on_the_note( qtbot.waitUntil(lambda: _row_exists(gui, "tshared"), timeout=BROADCAST_TIMEOUT) pm.lock("q10.IF", "tshared") qtbot.waitUntil( - lambda: gui.state.locks.get("q10.IF") - == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True), + lambda: ( + gui.state.locks.get("q10.IF") + == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) qtbot.waitUntil( - lambda: gui.typesPane.entryWidgets.get("IF", {}).get("toggle") - is not None, + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("toggle") is not None, timeout=BROADCAST_TIMEOUT, ) @@ -3139,8 +3182,9 @@ def test_the_types_tab_names_skipped_locks_on_the_note( assert gui.locksController.armed_type_lock == ("qubit", "IF") gui.locksController.pick_lock_target("tshared") qtbot.waitUntil( - lambda: pm.get_type("qubit").parameters["IF"]["target"] - == f"{PM_NAME}.tshared", + lambda: ( + pm.get_type("qubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared" + ), timeout=BROADCAST_TIMEOUT, ) assert gui.typesPane.entriesNote.text() == "" @@ -3170,8 +3214,10 @@ def test_the_types_tab_show_button_and_also_types( second_pm.add_type("smallq") second_pm.add_type_parameter("smallq", "IF", unit="Hz") qtbot.waitUntil( - lambda: _instance_row_items(gui, "q10") is not None - and _instance_row_items(gui, "q10")[2].text() == "also smallq", + lambda: ( + _instance_row_items(gui, "q10") is not None + and _instance_row_items(gui, "q10")[2].text() == "also smallq" + ), timeout=BROADCAST_TIMEOUT, ) @@ -3200,9 +3246,7 @@ def test_the_types_tab_shows_server_errors_and_empty_names( gui.tabs.setCurrentIndex(1) gui.typesPane.newTypeEdit.setText("errtype") gui.typesPane.addTypeButton.click() - qtbot.waitUntil( - lambda: "errtype" in pm.list_types(), timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: "errtype" in pm.list_types(), timeout=BROADCAST_TIMEOUT) qtbot.waitUntil( lambda: gui.typesPane.selectedType == "errtype", timeout=BROADCAST_TIMEOUT, @@ -3315,8 +3359,10 @@ def test_removing_a_target_confirms_and_cancel_keeps_the_server_untouched( _wait_until_broadcasts_arrive(qtbot, gui, second_pm) second_pm.lock("q01.IF", "q02.IF") qtbot.waitUntil( - lambda: gui.state.locks.get("q01.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) # a second Follower whose Lock the second Client unlocked: the @@ -3324,8 +3370,10 @@ def test_removing_a_target_confirms_and_cancel_keeps_the_server_untouched( second_pm.lock("q03.IF", "q02.IF") second_pm.unlock("q03.IF") qtbot.waitUntil( - lambda: gui.state.locks.get("q03.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=False), + lambda: ( + gui.state.locks.get("q03.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=False) + ), timeout=BROADCAST_TIMEOUT, ) assert gui.removalDialog is None @@ -3341,17 +3389,12 @@ def _cancel_dialog(): ) assert "q01.IF (locked)" in dialog.text() assert "q03.IF (unlocked)" in dialog.text() + assert dialog.standardButtons() & QtWidgets.QMessageBox.StandardButton.Ok assert ( - dialog.standardButtons() - & QtWidgets.QMessageBox.StandardButton.Ok + dialog.standardButtons() & QtWidgets.QMessageBox.StandardButton.Cancel ) - assert ( - dialog.standardButtons() - & QtWidgets.QMessageBox.StandardButton.Cancel - ) - assert ( - dialog.defaultButton() - is dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel) + assert dialog.defaultButton() is dialog.button( + QtWidgets.QMessageBox.StandardButton.Cancel ) dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel).click() @@ -3387,9 +3430,7 @@ def _accept_dialog(): QtCore.QTimer.singleShot(0, _accept_dialog) _row_remove_button(gui, "q02.IF").click() - qtbot.waitUntil( - lambda: not pm.has_param("q02.IF"), timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: not pm.has_param("q02.IF"), timeout=BROADCAST_TIMEOUT) qtbot.waitUntil( lambda: pm.get_lock("q01.IF") is None, timeout=BROADCAST_TIMEOUT ) @@ -3403,9 +3444,7 @@ def _accept_dialog(): # a parameter without Followers is removed with no dialog gui.removeParameter("other.x") - qtbot.waitUntil( - lambda: not pm.has_param("other.x"), timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: not pm.has_param("other.x"), timeout=BROADCAST_TIMEOUT) assert gui.removalDialog is None finally: gui.model.stopListener() @@ -3427,8 +3466,10 @@ def test_removing_a_target_falls_back_to_the_client_side_followers( _wait_until_broadcasts_arrive(qtbot, gui, second_pm) second_pm.lock("q01.IF", "q02.IF") qtbot.waitUntil( - lambda: gui.state.locks.get("q01.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) @@ -3479,8 +3520,10 @@ def test_the_lock_shortcuts_arm_unlock_and_switch_tabs( _wait_until_broadcasts_arrive(qtbot, gui, second_pm) second_pm.lock("q01.IF", "q02.IF") qtbot.waitUntil( - lambda: gui.state.locks.get("q01.IF") - == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), timeout=BROADCAST_TIMEOUT, ) @@ -3575,9 +3618,7 @@ def test_a_parameter_update_for_an_unknown_row_recomputes_the_tints( unit="Hz", ) ) - qtbot.waitUntil( - lambda: _row_exists(gui, "q02.IF"), timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: _row_exists(gui, "q02.IF"), timeout=BROADCAST_TIMEOUT) tint = _type_tint(gui, "qubit") assert tint is not None for item in _row_items(gui, "q02.IF"): @@ -3594,9 +3635,7 @@ def test_a_parameter_update_for_an_unknown_row_recomputes_the_tints( unit="Hz", ) ) - qtbot.waitUntil( - lambda: _row_exists(gui, "q03.IF"), timeout=BROADCAST_TIMEOUT - ) + qtbot.waitUntil(lambda: _row_exists(gui, "q03.IF"), timeout=BROADCAST_TIMEOUT) for item in _row_items(gui, "q03.IF"): assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint assert _row_items(gui, "q03.IF")[3].data(GUTTER_ROLE) == ["qubit"] diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py index 20db25e..60c8278 100644 --- a/test/pytest/test_pm_locks.py +++ b/test/pytest/test_pm_locks.py @@ -204,6 +204,7 @@ def test_parameter_manager_creates_managed_parameters(): # Lock API on the Parameter Manager (plan task 1.2, D9) # --------------------------------------------------------------------------- + @pytest.fixture def pm(tmp_path, monkeypatch): """A fresh Parameter Manager in an empty working directory, with a few @@ -300,9 +301,7 @@ def test_unknown_path_raises_naming_the_path_and_changes_nothing(pm, call): call(pm) assert pm.list_locks() == { - "q01.x": PMLockBluePrint( - target="parameter_manager.q01Data.IF", locked=True - ) + "q01.x": PMLockBluePrint(target="parameter_manager.q01Data.IF", locked=True) } @@ -449,9 +448,7 @@ def test_relock_refuses_a_cycle_and_stays_unlocked(pm): pm.unlock("q01.x") q01_data_if = pm.parameter("q01Data.IF") q01_data_if._target = pm.parameter("q01.x") - q01_data_if.lock = PMLockBluePrint( - target="parameter_manager.q01.x", locked=True - ) + q01_data_if.lock = PMLockBluePrint(target="parameter_manager.q01.x", locked=True) with pytest.raises( ValueError, @@ -487,9 +484,7 @@ def test_toggle_lock_switches_both_ways(pm): def test_calls_without_a_lock_raise_naming_the_path(pm): for call in (pm.unlock, pm.relock, pm.toggle_lock, pm.remove_lock): - with pytest.raises( - ValueError, match="parameter_manager.q01.x has no Lock" - ): + with pytest.raises(ValueError, match="parameter_manager.q01.x has no Lock"): call("q01.x") diff --git a/test/pytest/test_pm_persistence.py b/test/pytest/test_pm_persistence.py index f0e18bc..e6700c8 100644 --- a/test/pytest/test_pm_persistence.py +++ b/test/pytest/test_pm_persistence.py @@ -814,9 +814,7 @@ def test_a_key_of_another_instrument_is_refused_up_front(): "parameters": {"other.a": {"value": 1, "unit": "u"}}, "types": {}, } - with pytest.raises( - ValueError, match="does not belong to this Parameter Manager" - ): + with pytest.raises(ValueError, match="does not belong to this Parameter Manager"): pm.fromParamDict(doc) assert pm.a() == 1 @@ -1344,9 +1342,7 @@ def test_clear_all_emits_type_updates_then_lock_updates_and_nothing_else( assert "_globals" not in pm.submodules -def test_switch_to_an_unknown_profile_raises_and_saves_nothing( - tmp_path, monkeypatch -): +def test_switch_to_an_unknown_profile_raises_and_saves_nothing(tmp_path, monkeypatch): """An unknown profile raises ``ValueError`` before anything happens: the current profile file keeps its contents and modification time, the state of the Parameter Manager is untouched, and no file is @@ -1371,9 +1367,7 @@ def test_switch_to_an_unknown_profile_raises_and_saves_nothing( ] -def test_refresh_and_list_profiles_are_the_same_around_a_switch( - tmp_path, monkeypatch -): +def test_refresh_and_list_profiles_are_the_same_around_a_switch(tmp_path, monkeypatch): """``refresh_profiles``/``list_profiles`` are unchanged by a switch: they list the same profile files before and after — plus the current profile's file when the switch's save creates it.""" diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py index a075d40..1f286f7 100644 --- a/test/pytest/test_pm_types.py +++ b/test/pytest/test_pm_types.py @@ -415,7 +415,10 @@ def test_effective_set_names_every_duplicated_path(pm): # every offending path, not the first (rule 3) message = str(excinfo.value) - assert "parameter path(s) 'readout.IF', 'readout.window' appear more than once" in message + assert ( + "parameter path(s) 'readout.IF', 'readout.window' appear more than once" + in message + ) assert message.endswith("effective set of Type 'qubit'") @@ -938,8 +941,12 @@ def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter(pm): message = str(excinfo.value) assert "cannot create parameter 'q01.octave_gain.x'" in message assert "cannot create parameter 'q02.octave_gain.x'" in message - assert "'q01.octave_gain' is a parameter, and cannot have child parameters" in message - assert "'q02.octave_gain' is a parameter, and cannot have child parameters" in message + assert ( + "'q01.octave_gain' is a parameter, and cannot have child parameters" in message + ) + assert ( + "'q02.octave_gain' is a parameter, and cannot have child parameters" in message + ) # nothing was mutated assert pm.get_type("qubit").parameters == { "octave_gain": {"default": 10, "unit": "dB", "target": None} @@ -957,7 +964,9 @@ def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter_group(pm): with pytest.raises( ValueError, - match=re.escape("cannot create parameter 'q01.IF': 'q01.IF' is already a Parameter Group"), + match=re.escape( + "cannot create parameter 'q01.IF': 'q01.IF' is already a Parameter Group" + ), ): pm.add_type_parameter("qubit", "IF", default=1, unit="Hz") @@ -1061,9 +1070,7 @@ def test_set_type_parameter_default_refuses_paths_that_are_not_its_own_entries(p with pytest.raises( ValueError, - match=re.escape( - "'readout.IF' is not an entry of Type 'qubit' itself" - ), + match=re.escape("'readout.IF' is not an entry of Type 'qubit' itself"), ): pm.set_type_parameter_default("qubit", "readout.IF", 1) with pytest.raises( @@ -1113,9 +1120,7 @@ def test_set_type_parameter_unit_refuses_paths_that_are_not_its_own_entries(pm): with pytest.raises( ValueError, - match=re.escape( - "'readout.IF' is not an entry of Type 'qubit' itself" - ), + match=re.escape("'readout.IF' is not an entry of Type 'qubit' itself"), ): pm.set_type_parameter_unit("qubit", "readout.IF", "V") with pytest.raises( @@ -2142,8 +2147,7 @@ def test_lock_type_parameter_locks_every_current_instance_parameter(pm): with pytest.raises( ValueError, match=re.escape( - "parameter_manager.q01.IF is locked to " - "parameter_manager._globals.qubit.IF" + "parameter_manager.q01.IF is locked to parameter_manager._globals.qubit.IF" ), ): pm.set("q01.IF", 1) @@ -2189,9 +2193,7 @@ def test_lock_type_parameter_with_no_instances_stores_the_rule(pm): ) -def test_lock_type_parameter_skips_a_lock_on_another_target_with_a_warning( - pm, caplog -): +def test_lock_type_parameter_skips_a_lock_on_another_target_with_a_warning(pm, caplog): put_qubit_instances(pm) pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") pm.lock("q01.IF", "q00.IF") @@ -2617,8 +2619,7 @@ def test_unlock_type_parameter_leaves_every_lock_in_place(pm): with pytest.raises( ValueError, match=re.escape( - "parameter_manager.q01.IF is locked to " - "parameter_manager._globals.qubit.IF" + "parameter_manager.q01.IF is locked to parameter_manager._globals.qubit.IF" ), ): pm.set("q01.IF", 1) @@ -4010,9 +4011,7 @@ def test_lock_type_parameter_round_trips_over_the_wire(param_manager): try: params.add_type(PROXY_LOCK_TYPE) params.add_type_parameter(PROXY_LOCK_TYPE, "IF", default=5e9, unit="Hz") - params.add_type_parameter( - PROXY_LOCK_TYPE, "octave_gain", default=10, unit="dB" - ) + params.add_type_parameter(PROXY_LOCK_TYPE, "octave_gain", default=10, unit="dB") for name in PROXY_LOCK_INSTANCES: params.add_instance(PROXY_LOCK_TYPE, name) params.update() @@ -4030,9 +4029,7 @@ def test_lock_type_parameter_round_trips_over_the_wire(param_manager): # a locked Instance parameter pulls the Globals Target's value; # the Target is set through the server-side Parameter Group's set # (the client proxy's own set is qcodes' local, deprecated one) - cli.call( - "parameter_manager.set", f"_globals.{PROXY_LOCK_TYPE}.IF", 7e9 - ) + cli.call("parameter_manager.set", f"_globals.{PROXY_LOCK_TYPE}.IF", 7e9) assert getattr(params, PROXY_LOCK_INSTANCES[0]).IF() == 7e9 # a Follower locked to another Target comes back as the skipped