Skip to content

Multiple top-level Controllers per FastCS app (#351)#360

Merged
gilesknap merged 41 commits into
mainfrom
multiple-controllers
May 19, 2026
Merged

Multiple top-level Controllers per FastCS app (#351)#360
gilesknap merged 41 commits into
mainfrom
multiple-controllers

Conversation

@gilesknap
Copy link
Copy Markdown
Contributor

@gilesknap gilesknap commented May 5, 2026

Fixes #353
Fixes #354
Fixes #355
Fixes #356
Fixes #357
Fixes #358
Fixes #359

Foundation slice (#353) wires multiple top-level Controllers end-to-end through REST. The EPICS CA slice (#354) layers the first transport-side multi-root on top. The EPICS PVA slice (#355) layers the second transport-side multi-root on top. The GraphQL slice (#356) layers the third — one combined schema with id-keyed top-level Query fields. The Tango slice (#357) layers the fourth — one Tango Device Server hosts N devices, one per controller, with the id forming the leading device-name segment. The GUI/docs slice (#358) layers the fifth — per-id GUI/docs files plus an always-emitted index file. The demo+rename slice (#359) lands the user-facing surface — bundled demo hosts two controllers, the demo config is renamed controller.yamlfastcs.yaml, and a manual migration guide is added. All seven are on this branch, so each commit below names the slice it serves; commits within a slice are independent sub-modules and are green individually. Two further commits added during review tighten the YAML shape — flat per-id options with a required type: discriminator — and are folded in here so the migration guide describes the final shape in one place. A tail commit (c8adfee7) further refines the shape: controllers: is a list of entries (each carrying its own id:), and id is gone from Python entirely — the launcher seeds each root Controller's path via set_path([entry.id]) once before serve.

Fixes #353 — multi-controller foundation (REST tracer)

# Commit Headline
1 5bb366cc Add Controller.id lifecycle for multi-controller foundation
2 0d4a3296 Add pv_prefix_from_path utility for path-based PV derivation
3 893487db Add validate_rest_id for REST controller id validation
4 6dd956de Unify Transport.connect signature on list[ControllerAPI]
5 0b2e1985 Route REST routes per controller id; reject illegal ids at connect
6 011bb683 Add multi-class launch() with dict-by-id controllers schema
7 d4ff267e Wire FastCS multi-controller end-to-end (REST)

Fixes #354 — EPICS CA multi-root softioc with id-based PV prefix

# Commit Headline
8 c8adb33b EPICS CA multi-root softioc with id-based PV prefix
9 c1b95a29 Update EPICS multi-transport docs for id-based prefix

Fixes #355 — EPICS PVA multi-root with N PVI roots

# Commit Headline
10 43956050 EPICS PVA multi-root with N PVI roots
11 5317473f Use literal markup for P4PIOC docstring refs

Fixes #356 — GraphQL combined schema with id-keyed top-level Query fields

# Commit Headline
12 e5217785 GraphQL combined schema with id-keyed top-level Query fields

Fixes #358 — GUI/docs emission: per-id files plus index file

# Commit Headline
13 fc8e710b GUI/docs emission: per-id files plus index file (#358)
14 f2c7cef9 Fix tutorial emphasize-lines after #358 snippet collapse

Fixes #359 — Demo, migration guide, and config file rename to fastcs.yaml

# Commit Headline
15 f6600bc9 Demo two controllers, rename controller.yaml -> fastcs.yaml, migration guide

Fixes #357 — Tango multi-device with id in device name

# Commit Headline
16 207d68d8 Tango multi-device per controller with id in device name

Refinements (in addition to the original PRD)

Tighten the multi-controller YAML shape introduced above: each entry under controllers: now exposes the controller's options fields directly as siblings of a required type: discriminator, instead of a nested controller: block. Pydantic's discriminated union does the dispatch and per-class validation in one pass; consumers reading the published JSON schema can identify each entry's class without knowing how launch() was called.

# Commit Headline
17 4de23e6b Flatten controllers entry: inline options fields next to type
18 439f2fe9 Make type: discriminator mandatory on every controllers entry
19 7ab10fee Use module-level registry instead of dynamic Entry-class attributes

Fixes after code review

A second pass after the initial review surfaced eleven follow-up issues (#361#371). Each was triaged in parallel and fixed on its own branch; the commits below land them on multiple-controllers. #362 was decision-only and resolved as already-implemented by commit 439f2fe9 above (option 2: type: discriminator mandatory in all modes).

# Commit Headline Fixes
20 c8be8d69 Drop stale nitpick_ignore for controller_pv_prefix #364
21 142e4e8d Decouple multi-controller doc example from demo #370
22 d341fff8 Call set_id on controllers in tutorial snippets #363
23 2cf219bc Drop dead path guard in GraphQL transport connect #366
24 f0551410 Thread expects_options flag through entry registry #361
25 04d36b2c Detect colliding Tango device-class names at connect #371
26 c87711ef Hoist shared EPICS id-validation skeleton into common util #365
27 6907741d Repoint EPICS_MAX_NAME_LENGTH consumers at canonical home #365
28 b284afe5 Drop dead EpicsDocs shim #367
29 4853d0eb Use controller id verbatim in GUI index DeviceRef #368
30 6db26ccf Fail fast on punctuation-only controller ids in EPICS GUI emission #369

Tail refinement: list-form controllers:, no Python id

A final tail commit restores the pre-#360 path-propagation flow and removes the transient id-as-Python-concept that the PR introduced. controllers: becomes a list of entries (each carrying its own id: sibling of type:); the launcher seeds each root Controller's _path via set_path([entry.id]) once before serve, and id is gone from Python entirely (no Controller.id, no set_id(), no _id attribute). User Controller.__init__ is unchanged from the rest of this PR — only the launcher and direct-construction call sites changed. Duplicate ids in the list are rejected at run time by _instantiate_controllers (replacing the dict-mapping safety net that no longer exists). Earlier review commit #22 (d341fff8, "Call set_id on controllers in tutorial snippets") is superseded by this tail; the snippets now call set_path(["DEMO"]).

# Commit Headline
31 c8adfee7 Restore pre-#360 path flow with launcher-seeded root id

Final YAML shape per entry:

controllers:
  - id: MAIN
    type: TemperatureController
    ip_settings: { ip: localhost, port: 25565 }
    num_ramp_controllers: 4

How to review

Recommend commit-by-commit — each commit's body explains its sub-module and the tests that cover it, and the suite is green at every commit.

pytest tests/test_multi_controller.py tests/test_launch.py tests/test_control_system.py tests/transports/graphQL/ tests/transports/epics/test_emission.py tests/transports/epics/ca/test_gui.py tests/transports/tango/

(GK: yeah - or maybe just go through the tutorial!)

Suggested follow-up: real-world validation via fastcs-catio

A good shake-down for this branch is to bump fastcs-catio to a build of this version and run it against real hardware:

  • Confirm the IOC still starts and serves the expected (now id-prefixed) PVs.
  • Confirm the generated GUI still renders correctly end-to-end.
  • Check that the diff required in catio to adopt the new API surface is relatively minor and that the new shape (list-of-entries controllers: YAML with per-entry id:, multi-class launch(), controller_apis plural accessor, launcher-seeded Controller._path) reads sensibly in a non-trivial app.

Feed the resulting diff and any rough edges back into a new docs page "How to migrate to fastcs 0.11.0" — at minimum covering the YAML schema change (controller: → list-form controllers: with id: and type: per entry), the Controller path semantics (id-as-path-root seeded by the launcher; no Controller.id in user code), the unified Transport.connect(list[ControllerAPI]) signature, and the EPICS CA prefix derivation (pv_prefix_from_path).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Multi-controller support: host multiple controllers with distinct IDs exposed across transports (EPICS, REST, GraphQL, Tango).
    • Per-controller GUI/docs emission and indexed outputs.
  • Bug Fixes & Changes

    • Configuration format: single controller:controllers: list (each requires id and type).
    • PV/route/device prefixes derived from controller IDs/path; transport options no longer carry per-controller prefixes.
    • CLI/docs updated to reference fastcs.yaml and output_dir.

Review Change Stack

gilesknap and others added 6 commits May 5, 2026 14:20
Introduce a stable per-controller identifier set once by the launcher
between __init__ and initialise(). Reading id before it is set raises a
RuntimeError, and setting twice raises. __repr__ surfaces the id once
set, and create_api_and_tasks now seeds the root ControllerAPI path with
[id] so sub-APIs become [id, sub].

Backwards compatible: when id is unset (existing single-controller
launcher path), the API path remains empty and behaviour is unchanged.

Part of #353.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure utility that derives an EPICS PV prefix from a controller path:
the first segment (controller id) is used verbatim, while later segments
are converted snake_case -> PascalCase. EPICS adopts this in #354 to
replace the existing root-prefix-plus-pascalled-path approach for
multi-controller IOCs.

D2 module of #353.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject controller ids that aren't safe in a REST URL path: empty or
containing characters outside the loosest URL-safe set
([A-Za-z0-9_-]+). The error message includes the offending id so
startup failures are unambiguous. Hookup into RestTransport.connect
follows in the multi-controller routing slice.

D3-REST module of #353.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every transport's connect() now takes list[ControllerAPI] uniformly. The
existing single-controller transports (EPICS CA, EPICS PVA, GraphQL,
Tango, REST) accept a list-of-one via a shared _expect_single helper
and behave as before. FastCS.serve passes [self.controller_api]. True
multi-controller support per transport will be wired in subsequent
slices.

This is a pure refactor: existing tests are updated to the new
list-of-one call shape, no behaviour changes for any transport.

Part of #353.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RestServer now accepts list[ControllerAPI] and adds attribute and
command routes for each. RestTransport hooks validate_rest_id into
connect() so illegal ids fail fast with a clear startup error. Existing
path-based routing already prefixes routes with controller_api.path[0],
so once Controller.set_id seeds the API path, REST URLs become
GET /{id}/{sub}/{attr} for free.

Two new tests in tests/test_multi_controller.py cover routing two
distinct ids in one process and rejecting an id with an illegal
character.

Part of #353.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`launch()` now accepts either a single Controller class or a list of
classes; the generated `fastcs.yaml` schema replaces the top-level
`controller:` key with a dict of `controllers:` keyed by id. Each value
carries a `type:` discriminator (defaults to the class `__name__`,
overridable via `type_name: ClassVar[str]`) and an optional `controller:`
options block. Single-class registration may omit `type:` via a default.
Duplicate ids are rejected at YAML load time by ruamel's safe loader.

Wiring through `FastCS` for >1 controller lands in the next slice; for
now multi-entry configs validate cleanly but the run command exits with
a clear LaunchError pointing at the deferred work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Warning

Rate limit exceeded

@gilesknap has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 3 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 769e7e63-feb2-46e9-bec3-cf14989dfe67

📥 Commits

Reviewing files that changed from the base of the PR and between fb03263 and c0fbb39.

📒 Files selected for processing (74)
  • .vscode/launch.json
  • docs/conf.py
  • docs/how-to/launch-framework.md
  • docs/how-to/migrate-to-multi-controller.md
  • docs/how-to/multiple-transports.md
  • docs/snippets/dynamic.py
  • docs/snippets/static04.py
  • docs/snippets/static05.py
  • docs/snippets/static06.py
  • docs/snippets/static07.py
  • docs/snippets/static08.py
  • docs/snippets/static09.py
  • docs/snippets/static10.py
  • docs/snippets/static11.py
  • docs/snippets/static12.py
  • docs/snippets/static13.py
  • docs/snippets/static14.py
  • docs/snippets/static15.py
  • docs/tutorials/static-drivers.md
  • src/fastcs/control_system.py
  • src/fastcs/controllers/controller.py
  • src/fastcs/demo/controller.yaml
  • src/fastcs/demo/fastcs.yaml
  • src/fastcs/demo/schema.json
  • src/fastcs/demo/simulation/temp_controller.yaml
  • src/fastcs/launch.py
  • src/fastcs/transports/__init__.py
  • src/fastcs/transports/epics/__init__.py
  • src/fastcs/transports/epics/ca/ioc.py
  • src/fastcs/transports/epics/ca/transport.py
  • src/fastcs/transports/epics/ca/util.py
  • src/fastcs/transports/epics/docs.py
  • src/fastcs/transports/epics/emission.py
  • src/fastcs/transports/epics/gui.py
  • src/fastcs/transports/epics/options.py
  • src/fastcs/transports/epics/pva/ioc.py
  • src/fastcs/transports/epics/pva/transport.py
  • src/fastcs/transports/epics/pva/util.py
  • src/fastcs/transports/epics/util.py
  • src/fastcs/transports/graphql/graphql.py
  • src/fastcs/transports/graphql/transport.py
  • src/fastcs/transports/graphql/util.py
  • src/fastcs/transports/rest/rest.py
  • src/fastcs/transports/rest/transport.py
  • src/fastcs/transports/rest/util.py
  • src/fastcs/transports/tango/dsr.py
  • src/fastcs/transports/tango/options.py
  • src/fastcs/transports/tango/transport.py
  • src/fastcs/transports/tango/util.py
  • src/fastcs/transports/transport.py
  • tests/assertable_controller.py
  • tests/benchmarking/controller.py
  • tests/conftest.py
  • tests/data/config.yaml
  • tests/data/schema.json
  • tests/example_p4p_ioc.py
  • tests/example_softioc.py
  • tests/test_control_system.py
  • tests/test_launch.py
  • tests/test_multi_controller.py
  • tests/transports/epics/ca/test_ca_util.py
  • tests/transports/epics/ca/test_gui.py
  • tests/transports/epics/ca/test_initial_value.py
  • tests/transports/epics/ca/test_softioc.py
  • tests/transports/epics/pva/test_p4p.py
  • tests/transports/epics/pva/test_pva_gui.py
  • tests/transports/epics/pva/test_pva_util.py
  • tests/transports/epics/test_emission.py
  • tests/transports/epics/test_pv_prefix.py
  • tests/transports/graphQL/test_graphql.py
  • tests/transports/rest/test_id_validator.py
  • tests/transports/rest/test_rest.py
  • tests/transports/tango/test_dsr.py
  • tests/transports/tango/test_tango_util.py
📝 Walkthrough

Walkthrough

FastCS refactored for multi-controller operation: launch schema and CLI accept multiple controllers; FastCS and controllers manage per-controller APIs/lifecycle; transports accept lists of ControllerAPI and validate ids; EPICS PV prefix derivation and emission were added; Tango naming and many tests/docs/examples updated.

Changes

Multi-Controller Support

Layer / File(s) Summary
Core multi-controller foundation
src/fastcs/control_system.py, src/fastcs/controllers/controller.py
FastCS now accepts single or multiple controllers, derives unique context keys, manages multi-controller APIs and task aggregation, and coordinates transports and lifecycle hooks across all controllers.
Launch framework multi-controller
src/fastcs/launch.py
launch() accepts single or multiple controller classes, builds dynamic Pydantic entry models with type discriminators and inlined options, validates and instantiates controllers from YAML config with ID seeding via set_path().
Transport abstraction refactoring
src/fastcs/transports/transport.py
Transport.connect() signature updated to accept list of ControllerAPI instances; base class adds _expect_single() helper for single-controller-only transports.
EPICS utilities and PV prefix derivation
src/fastcs/transports/epics/util.py
New pv_prefix_from_path() derives PV prefix from controller path (root segment verbatim, others PascalCase with colon joining); EPICS_MAX_NAME_LENGTH constant; validate_epics_pv_id() enforces length limits across nested controller APIs.
EPICS CA multi-controller
src/fastcs/transports/epics/ca/ioc.py, src/fastcs/transports/epics/ca/transport.py, src/fastcs/transports/epics/ca/util.py
EpicsCAIOC refactored to initialize multiple ControllerAPI instances with per-controller PV prefixes; EpicsCATransport.connect() accepts controller list, validates CA IDs, emits per-controller GUI/docs files via shared helpers.
EPICS PVA multi-controller
src/fastcs/transports/epics/pva/ioc.py, src/fastcs/transports/epics/pva/transport.py, src/fastcs/transports/epics/pva/util.py
P4PIOC refactored to build providers per controller; EpicsPVATransport.connect() accepts controller list, validates PVA IDs, constructs multi-controller IOC, emits docs/GUI via shared helpers.
EPICS emission system
src/fastcs/transports/epics/emission.py, src/fastcs/transports/epics/gui.py
New emission.py module with emit_gui_files() and emit_docs_files() that write per-controller output files plus stable index files; EpicsGUI derives PV prefix from controller path via pv_prefix_from_path(); build_device() method returns Device object for external formatting.
EPICS options and GUI restructuring
src/fastcs/transports/epics/options.py, src/fastcs/transports/epics/__init__.py, src/fastcs/transports/__init__.py
EpicsIOCOptions removed; replaced with empty EpicsCAOptions and EpicsPVAOptions discriminators; EpicsGUIOptions and EpicsDocsOptions use output_dir instead of output_path/path; default titles updated.
REST and GraphQL multi-controller
src/fastcs/transports/rest/rest.py, src/fastcs/transports/rest/transport.py, src/fastcs/transports/rest/util.py, src/fastcs/transports/graphql/graphql.py, src/fastcs/transports/graphql/transport.py, src/fastcs/transports/graphql/util.py
RestServer and GraphQLServer refactored to iterate controller lists; both transports validate IDs and initialize servers with full controller lists; GraphQL builds combined schema with per-controller query fields.
Tango multi-controller and device naming
src/fastcs/transports/tango/dsr.py, src/fastcs/transports/tango/transport.py, src/fastcs/transports/tango/options.py, src/fastcs/transports/tango/util.py
TangoDSR builds one device per controller; new utilities tango_dev_class_name() and tango_dev_name() derive device names from IDs; TangoTransport.connect() validates IDs and detects post-sanitisation collisions; dev_name field removed from TangoDSROptions.
Demo configuration with multiple controllers
src/fastcs/demo/fastcs.yaml, src/fastcs/demo/schema.json, src/fastcs/demo/simulation/temp_controller.yaml
New fastcs.yaml defining MAIN and AUX TemperatureController instances with distinct ports; schema.json updated with TemperatureControllerEntry, transport objects, and controllers array; simulation config split into multi-controller setup; old controller.yaml removed.
Comprehensive multi-controller tests
tests/test_multi_controller.py
New test module with helper controllers and _api_with_id() utility; tests for REST, EPICS CA/PVA, GraphQL, Tango routing and validation; end-to-end FastCS lifecycle test with controller lifecycle hooks and disconnection verification.
Launch and control system tests
tests/test_launch.py, tests/test_control_system.py
test_launch.py expanded with controller fixtures and schema generation assertions matching entry models with type discriminators; test_control_system.py updated to use controller_apis[0].
Transport-specific tests
tests/transports/epics/ca/test_ca_util.py, tests/transports/epics/pva/test_pva_util.py, tests/transports/epics/test_emission.py, tests/transports/epics/test_pv_prefix.py, tests/transports/rest/test_id_validator.py, tests/transports/tango/test_tango_util.py, and test updates across all transport modules
New validation and utility test modules; existing tests updated to create ControllerAPI with path and pass controller lists to connect() methods.
Migration and how-to documentation
docs/how-to/migrate-to-multi-controller.md, docs/how-to/launch-framework.md, docs/how-to/multiple-transports.md
New migration guide with step-by-step YAML schema changes; updated launch-framework and multiple-transports guides with new patterns and ID validation requirements across transports.
Code snippets and examples
docs/snippets/dynamic.py, docs/snippets/static*.py, docs/tutorials/static-drivers.md, tests/example_softioc.py, tests/example_p4p_ioc.py
All snippets updated to use controller.set_path() instead of EpicsIOCOptions pv_prefix; output_dir instead of output_path/path; transports constructed without explicit options; tutorial emphasis lines adjusted.
Test fixtures and helpers
tests/assertable_controller.py, tests/conftest.py, tests/benchmarking/controller.py, tests/data/config.yaml, tests/data/schema.json
AssertableControllerAPI accepts optional path; conftest.py Tango registration uses computed device names via helpers; fixtures use set_path() pattern; config and schema updated for multi-controller structure.
Documentation cleanup
.vscode/launch.json, docs/conf.py
Updated debug config to use fastcs.yaml; removed obsolete nitpick_ignore entry for deleted controller_pv_prefix.

Estimated code review effort: 🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • shihab-dls

"🐰 In the code-bunny burrow we hop with delight,
Two controllers now share the FastCS light.
Paths set with care, PVs find their true name,
Transports sing tidy, each id plays the game.
Hooray for the patch — now the system's more spry!"

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch multiple-controllers

@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 92.34450% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.20%. Comparing base (fb03263) to head (c0fbb39).
⚠️ Report is 42 commits behind head on main.

Files with missing lines Patch % Lines
src/fastcs/transports/epics/emission.py 89.04% 8 Missing ⚠️
src/fastcs/launch.py 92.63% 7 Missing ⚠️
src/fastcs/control_system.py 85.71% 5 Missing ⚠️
src/fastcs/transports/tango/dsr.py 75.00% 4 Missing ⚠️
src/fastcs/transports/transport.py 25.00% 3 Missing ⚠️
src/fastcs/transports/epics/pva/transport.py 88.23% 2 Missing ⚠️
src/fastcs/transports/graphql/graphql.py 94.73% 1 Missing ⚠️
src/fastcs/transports/graphql/util.py 85.71% 1 Missing ⚠️
src/fastcs/transports/tango/transport.py 93.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #360      +/-   ##
==========================================
- Coverage   91.24%   91.20%   -0.05%     
==========================================
  Files          70       72       +2     
  Lines        2604     2875     +271     
==========================================
+ Hits         2376     2622     +246     
- Misses        228      253      +25     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gilesknap gilesknap changed the title Multi-controller foundation: Controller.id, dict-by-id config, REST end-to-end (#353) Multi-controller foundation: Controller.id, multi-class launch, unified transports, REST per-id (#353) May 5, 2026
Copy link
Copy Markdown
Contributor Author

@gilesknap gilesknap left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5bb366c looks good.

Comment thread src/fastcs/demo/fastcs.yaml Outdated
gilesknap and others added 7 commits May 6, 2026 11:13
FastCS.__init__ now accepts Controller | Sequence[Controller]; serve()
loops initialise/post_initialise/connect/disconnect over every
controller, builds list[ControllerAPI], and hands the full list to each
transport.connect(). IPython context exposes parallel dicts
(controllers, controller_apis) keyed by controller id (falling back to
class name when no id is set), and the startup log line lists controller
ids. fastcs.controller_api singular accessor is replaced with the
controller_apis list.

The temporary >1-controller LaunchError stub in launch._launch.run is
removed; multi-entry configs are wired through FastCS directly. Single
Controller direct construction continues to work via the union arg, so
docs snippets are unchanged.

A new end-to-end test in tests/test_multi_controller.py drives
FastCS.serve with two id-tagged controllers and a RestTransport, asserts
all four lifecycle hooks fire on each, and verifies /<id>/<attr>
routing plus combined OpenAPI through TestClient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EpicsCATransport now hosts every configured controller in a single
softioc, with each controller's id used verbatim as its PV prefix.
EpicsCAIOC takes list[ControllerAPI] and loops the existing
record/PVI/command builders per controller, deriving each prefix from
pv_prefix_from_path(api.path) (the D2 utility introduced in #353).
EpicsCATransport.connect drops _expect_single in favour of true
multi-controller; validate_ca_id runs at connect time and rejects ids
with illegal characters as well as setups whose longest derivable PV
prefix already exceeds the 60-character EPICS limit.

EpicsIOCOptions and its pv_prefix field are deleted. EpicsCAOptions and
EpicsPVAOptions empty placeholders preserve epicsca: / epicspva: as
fastcs.yaml discriminator keys (Pydantic union resolution is
positional, so a unique field name per transport is still load-bearing).
EpicsGUI no longer takes a separate prefix argument; PVs derive from
the controller path. PVA temporarily continues via _expect_single but
adopts pv_prefix_from_path so it gets the same id-based prefix and no
longer needs EpicsIOCOptions; full PVA multi-root work lands in #355.

tests/test_multi_controller.py grows a CA two-controllers-no-clash
scenario and a CA id-validation fail-fast case. tests/example_softioc,
tests/example_p4p_ioc, tests/benchmarking/controller, tests/conftest,
test_initial_value, test_p4p, test_softioc, test_gui, test_pva_gui and
the AssertableControllerAPI fixture all migrate to id-based naming
(controllers set their id, transports take no prefix). Demo
controller.yaml and both regenerated schema.json files reflect the new
EpicsCAOptions / EpicsPVAOptions schemas and removal of pv_prefix.
The 13 docs/snippets are exercised by tests/test_docs_snippets.py via
runpy, so they migrate in this commit too to keep the suite green at
every commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
multiple-transports.md and launch-framework.md still showed
EpicsIOCOptions(pv_prefix=...) in their Python and YAML examples.
Replace those with the id-based shape: controllers set their id (or
inherit it from the YAML controllers: dict key), and EpicsCATransport /
EpicsPVATransport take no prefix argument. The prose follows the API
that landed in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EpicsPVATransport now hosts every configured controller in one p4p
server, with each controller's id used verbatim as its PV prefix.
P4PIOC takes list[ControllerAPI] and builds one StaticProvider per
controller via the existing parse_attributes helper, so each controller
gets an independent :PVI root with no super-parent (per the PRD).
EpicsPVATransport.connect drops _expect_single in favour of true
multi-controller; validate_pva_id runs at connect time and rejects ids
with illegal characters as well as setups whose longest derivable PV
prefix already exceeds the 60-character EPICS limit.

validate_pva_id mirrors validate_ca_id and lives in transports/epics/
pva/util.py to keep id validation a per-transport concern. To share the
60-char constant without a cross-transport import, EPICS_MAX_NAME_LENGTH
moves up from ca/util.py to epics/util.py; ca/util.py re-imports it so
existing ca.util consumers (ca/ioc.py, test_softioc) are unaffected.

tests/test_multi_controller.py grows a PVA two-controllers-distinct-PVI
scenario (asserts each StaticProvider exposes its own root) and a PVA
id-validation fail-fast case. test_pva_util.py mirrors test_ca_util.py's
validator coverage. test_p4p.py::test_pvi_grouping shortens its UUID id
to 8 hex chars so the deepest derived prefix
(<id>:AdditionalChild:ChildChild) no longer trips the new 60-char check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sphinx is configured with `nitpicky = True` and `--fail-on-warning`, so
single-backticks in the new P4PIOC docstring (`StaticProvider`, `:PVI`)
were treated as :any: cross-references and failed to resolve. Switch to
double-backticks so they're inline literals instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the GraphQL transport into the multi-controller foundation:

- New `validate_graphql_id` enforces GraphQL `Name` syntax (the most
  restrictive of FastCS's transports — drives the lowest-common-denominator
  id-naming guidance for users mixing transports).
- `GraphQLServer` now accepts `list[ControllerAPI]` and assembles a single
  combined schema with one top-level Query (and Mutation, where applicable)
  field per controller id. Sub-API type names are path-joined to keep two
  controllers' identically-named sub-controllers from clashing in the schema.
- `GraphQLTransport.connect` validates ids fail-fast at startup.
- `tests/test_multi_controller.py` gains a two-controller combined-schema
  scenario and a per-transport id-validation case.
- The single-controller transport test is updated to namespace its queries
  under a controller id, matching the new contract; a latent bug in its
  `nest_mutation` helper (recursing through `nest_query`) is fixed in passing.
- `docs/how-to/multiple-transports.md` adds a charset table and notes
  GraphQL as the lowest common denominator for cross-transport ids.
D4 of #351 lands as a single transport-level module. Both EPICS
transports now invoke `emit_gui_files(controller_apis, options, builder)`
once with the full controller list, replacing the per-controller loop
that wrote everything to the same file. The module produces:

- One screen/docs file per controller at `output_dir/{id}.{ext}`,
  preserving the order in which controllers were declared in
  `fastcs.yaml`.
- An index file at the root of `output_dir` -- emitted even for a
  single controller, so the file layout is stable as the controller
  count changes.

The GUI index uses pvi's `DLSFormatter` directly (rather than the
convenience `format_index` wrapper) so that `DeviceRef.name` can be
coerced to satisfy pvi's `PascalStr` constraint when controller ids
legitimately start with a digit (e.g. UUID-flavoured test prefixes).
The docs side mirrors the GUI shape with a minimal markdown emitter --
just enough to lift `EpicsDocs.create_docs` off its prior no-op stub.

Knock-on schema/option changes:

- `EpicsGUIOptions.output_path` (single file) becomes `output_dir`
  (directory); ditto `EpicsDocsOptions.path` -> `output_dir`. The
  per-controller filename is derived from the controller id.
- The bundled demo, the 13 docs snippets, `tests/example_softioc.py`,
  the multi-transport how-to and the `launch-framework` how-to all
  migrate to the new field name. Both `schema.json` files are
  regenerated.
- `EpicsGUI` loses its `create_gui()` file-writing entry point in
  favour of a smaller `build_device(title) -> Device` helper that the
  emission module composes per controller.
- `tests/data/config.yaml` drops its `gui: {}` / `docs: {}` blocks --
  they were schema-fixture noise that now leaks generated files into
  the repo CWD when the launcher tests exercise `connect()`.

Tests:

- New `tests/transports/epics/test_emission.py` (D4 unit tests):
  per-id files plus index for single and multi-controller cases,
  declaration order preservation, missing-output-dir creation, PVA
  builder propagation, and the digit-leading-id coercion.
- `tests/transports/epics/ca/test_gui.py` gains a transport-level
  assertion that the index file is generated alongside per-controller
  files (per #358 acceptance criteria).
- `tests/test_multi_controller.py` gains a CA scenario that drives
  `EpicsCATransport.connect` end-to-end and asserts both per-id and
  index files for GUI and docs land in their configured `output_dir`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gilesknap gilesknap force-pushed the multiple-controllers branch from 8b7cf38 to fc8e710 Compare May 6, 2026 10:24
The #358 commit migrated the docs/snippets EpicsGUIOptions(...) calls
from a 3-line to a 1-line form via ruff format, shifting every line
below `gui_options = ...` up by 2 in static05/06/10/14/15.py. The
tutorial's `:emphasize-lines:` references for those snippets in
docs/tutorials/static-drivers.md were left pointing at the old
positions, which made `sphinx -W` fail with two "line number spec is
out of range" warnings on static05 and static10. Update each affected
range so the highlighted lines correspond to the same code as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n guide

Bundled demo now hosts two TemperatureController instances (MAIN/AUX) on
distinct ports so the multi-controller feature is visible end-to-end. The
demo simulation grows a second TempController on port 25566 with its own
sink to drive the AUX controller; the main one stays on 25565.

Renames src/fastcs/demo/controller.yaml -> src/fastcs/demo/fastcs.yaml.
The launcher already takes the config path as a CLI argument, so nothing
hard-codes the new name. .vscode/launch.json updated; schema.json
unchanged (the dict-by-id form was already in place).

docs/how-to/launch-framework.md examples migrate to fastcs.yaml and gain
a "Hosting multiple controllers" section. New
docs/how-to/migrate-to-multi-controller.md covers the breaking-change
manual migration steps: file rename, controller: -> controllers:{id}
dict, EpicsIOCOptions.pv_prefix removal, type: discriminator with
single-class inference, GUI/docs output_dir rename.

Fixes #359
gilesknap and others added 2 commits May 6, 2026 14:45
The IOC publishes PVs using the controller id verbatim (see
pv_prefix_from_path in transports/epics/util.py), but the per-controller
DeviceRef in the GUI index was upper-casing the id when writing the pv
attribute. For id="alpha" the per-controller .bob referenced alpha:Foo
(matching the IOC) while the index .bob referenced ALPHA, which the IOC
never publishes. Drop the .upper() so the index agrees with the IOC.

Fixes #368

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The EPICS CA id validator accepts `[A-Za-z0-9_-]+`, so ids like `"___"`
and `"-"` pass validation and reach `_coerce_pascal_name` in the GUI
emission path. That helper delegates to `pvi.device.enforce_pascal_case`,
which strips non-Pascal characters and unconditionally indexes `s[0]` on
the result. When every character is stripped the index raises
`IndexError`, blowing up GUI emission at `connect()` time with an opaque
traceback.

Pre-strip the id with the same regex pvi uses (`NON_PASCAL_CHARS_RE`)
and raise `ValueError` with a message that names the offending id when
the strip yields the empty string. Choosing fail-fast over an `"X"`
fallback keeps the failure traceable: a silent fallback would generate
nonsense GUI names that the user would have to reverse-engineer back to
the bad id.

Fixes #369

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the path-propagation flow to its pre-#360 shape: same
`set_path` / `add_sub_controller(name).set_path(parent.path + [name])`
/ `_build_api(self._path)` machinery, with the single addition that
the launcher seeds each root Controller's path via
`set_path([entry.id])` once before `serve`. The "root of the path"
moved from transport-config (`pv_prefix`, gone after #360) to
controller-config (the YAML `id:`, seeded into `_path`).

Drops `id` from Python entirely. `Controller._id`, `set_id()`, the
`id` property and the `_id` repr branch are removed. `id` is now a
YAML-label-only concept; `path` is the real Controller concept and
the launcher is the only place that translates one to the other.
User `Controller.__init__` is unchanged.

YAML schema flip (consistent with the multi-controller direction of
PR #360): `controllers:` becomes a list of entries, each carrying
`id:` as a sibling of the existing `type:` discriminator. Duplicate
ids are rejected at run time by `_instantiate_controllers` (replaces
the dict mapping-key safety net that no longer exists).

  controllers:
    - id: MAIN
      type: TemperatureController
      ip_settings: { ip: localhost, port: 25565 }
      num_ramp_controllers: 4

The transport-side change made by #360 stays —
`EpicsCATransport.connect` reads `pv_prefix_from_path(api.path)`
instead of a configured `pv_prefix`. Multi-controller cardinality
(also #360 territory) stays.

`FastCS._context_key` and direct-construction call sites read
`controller.path[0]` (with `IndexError` falling back to the class
name); tests, examples and snippets call `set_path([id])` instead
of `set_id(id)`. Migration guide, launch-framework guide,
multiple-transports guide, demo, all docs snippets, and the YAML
fixtures are updated to the list form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default the fastcs.yaml `type:` discriminator to
`<top-level-package>.<ClassName>` so Controllers shipped from
independently-distributed packages (e.g. fastcs_eiger, fastcs_pmac)
cannot collide on a short class name. An explicit
`type_name: ClassVar[str]` on the class still wins verbatim with no
prefix added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gilesknap gilesknap force-pushed the multiple-controllers branch from 5a0fe58 to 74aa39b Compare May 13, 2026 13:33
@gilesknap
Copy link
Copy Markdown
Contributor Author

@coretl hope this is what your whiteboard says! 74aa39b

@gilesknap gilesknap marked this pull request as ready for review May 19, 2026 10:44
@gilesknap
Copy link
Copy Markdown
Contributor Author

Fixes #120

@coretl please can you approve for the merge.

Copy link
Copy Markdown
Contributor

@coretl coretl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed in person, Tango, GQL and REST are likely to need changes, but let's merge as is and handle those in another PR

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/fastcs/transports/rest/rest.py (1)

154-166: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Bug: Command routes always use root controller's methods instead of current controller.

Line 158 references root_controller_api.command_methods but should use controller_api.command_methods to match the iteration variable. This causes all command routes to be registered from the root controller only, ignoring nested controller commands.

Compare with _add_attribute_api_routes (line 108) which correctly uses controller_api.attributes.

🐛 Proposed fix
 def _add_command_api_routes(app: FastAPI, root_controller_api: ControllerAPI) -> None:
     for controller_api in root_controller_api.walk_api():
         path = controller_api.path
 
-        for name, method in root_controller_api.command_methods.items():
+        for name, method in controller_api.command_methods.items():
             cmd_name = name.replace("_", "-")
             route = f"/{'/'.join(path)}/{cmd_name}" if path else cmd_name
             app.add_api_route(
                 f"/{route}",
                 _wrap_command(method.fn),
                 methods=["PUT"],
                 status_code=204,
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fastcs/transports/rest/rest.py` around lines 154 - 166, In
_add_command_api_routes, the loop is incorrectly iterating over
root_controller_api.command_methods causing all command routes to come from the
root controller; change the iteration to use controller_api.command_methods so
each controller_api.walk_api() uses its own commands (i.e., in function
_add_command_api_routes replace root_controller_api.command_methods with
controller_api.command_methods when building cmd_name and calling
app.add_api_route with _wrap_command(method.fn) and the existing PUT/204
options).
🧹 Nitpick comments (1)
src/fastcs/launch.py (1)

35-39: 💤 Low value

Module-level registry accumulates entries across test runs without cleanup.

_ENTRY_REGISTRY grows each time _build_entry_model is called. Since each call creates a new model class, tests calling launch() or _build_options_model() with the same controller classes will add new entries to the registry without clearing them. While this doesn't affect functionality (each entry maps to the correct controller), adding a pytest fixture to clear the registry between test runs would improve hygiene and prevent unbounded growth in long-running test suites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fastcs/launch.py` around lines 35 - 39, The module-level _ENTRY_REGISTRY
is never cleared and accumulates classes when _build_entry_model (and thus
launch or _build_options_model) is called repeatedly; add a cleanup hook for
tests by exposing a clear function or resetting the registry from tests:
implement a small utility like clear_entry_registry() that sets _ENTRY_REGISTRY
= {} (or clears it in-place) and call that from your pytest fixture (e.g., in a
teardown/fixture that runs between tests) so repeated calls to
_build_entry_model/launch/_build_options_model won't cause unbounded growth.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/fastcs/demo/schema.json`:
- Around line 3-7: The JSON schema objects (e.g., EpicsCAOptions and the various
transport wrapper and empty option object definitions added in this diff)
currently allow unknown keys; update each object schema by adding
"additionalProperties": false to harden validation so unknown/typo fields are
rejected. Locate each object definition introduced (names such as EpicsCAOptions
and the transport wrapper/option object entries referenced in the comment) and
add the additionalProperties flag at the same level as
"properties"/"type"/"title" for each schema definition.

In `@src/fastcs/transports/__init__.py`:
- Around line 4-10: The current __init__.py swallows ImportError for the whole
block so EpicsCAOptions and EpicsPVAOptions are not exported when
.epics.ca.transport fails; instead import and expose option classes separately
from the transport import: import EpicsCAOptions, EpicsDocsOptions,
EpicsGUIOptions, EpicsPVAOptions in their own try/except or top-level import so
they always get exported, and import EpicsCATransport in a separate try/except
that sets EpicsCATransport = None (or omits it) on failure; update references to
EpicsCATransport, EpicsCAOptions, and EpicsPVAOptions accordingly so config
consumers still receive the option classes even if the CA transport import
fails.

In `@src/fastcs/transports/epics/util.py`:
- Around line 35-39: The local variable named id shadows Python's builtin and
should be renamed (e.g., controller_id) to satisfy Ruff A001; update the
assignment from controller_api.path[0] to controller_id and replace subsequent
uses in the validation check using id_re.fullmatch(controller_id) and the
f-string (f"Controller id {controller_id!r} ...") while preserving
transport_label and the raise ValueError behavior.

In `@src/fastcs/transports/graphql/graphql.py`:
- Around line 35-44: The variable named id shadows the Python builtin; rename
the local variable assigned from controller_api.path[0] to controller_id and
update its usage in this block (the GraphQLAPI instantiation and the subsequent
calls to _wrap_as_field and create_type), e.g. replace references to id with
controller_id when creating f"{id}Query"/f"{id}Mutation" and when passing the
name into _wrap_as_field so GraphQLAPI(controller_api), sub_tree.queries,
sub_tree.mutations, queries.append(...), and mutations.append(...) use
controller_id instead of id.

In `@src/fastcs/transports/graphql/transport.py`:
- Around line 23-24: Check for an empty path before calling validate_graphql_id
to avoid IndexError: in the loop over controller_apis, guard access to
api.path[0] by first verifying api.path is truthy (non-empty) — e.g., only call
validate_graphql_id(api.path[0]) when api.path exists — mirroring the guard used
in RestTransport.connect(); update the loop that references controller_apis and
api.path to perform this check.

In `@src/fastcs/transports/tango/transport.py`:
- Around line 23-24: The loop over controller_apis in the Tango transport
accesses api.path[0] without guarding for empty paths (see the line assigning id
= api.path[0]); add a guard like "if api.path:" before attempting to read
path[0] or, if empty paths are invalid for this transport, raise a clear
ValueError with context (include controller/api identifier). Update the code in
the same loop (the controller_apis iteration) to either skip/handle empty
api.path entries or raise the explicit error so an IndexError cannot occur.

In `@tests/test_multi_controller.py`:
- Around line 366-367: Replace the fixed sleep after cancelling the background
task with an explicit await of the cancelled task to ensure deterministic
teardown: after calling task.cancel(), do try: await task except
asyncio.CancelledError: pass so the serve task (the variable task) has fully
finished/shutdown before the test proceeds, rather than relying on await
asyncio.sleep(0.1).

In `@tests/transports/epics/pva/test_pva_util.py`:
- Around line 7-15: Rename the test parameter named "id" to avoid shadowing the
built-in id() function (e.g., use "pva_id"); update both parametrized decorators
and the test function signatures and bodies (test_validate_pva_id_accepts_valid
and test_validate_pva_id_rejects_illegal_characters) so they pass
ControllerAPI(path=[pva_id]) to validate_pva_id instead of using "id".

In `@tests/transports/epics/test_emission.py`:
- Around line 34-37: Rename the parameter id in the helper function _api_with_id
to avoid shadowing the built-in id(); update the function signature and every
place the parameter is referenced (e.g., the call to controller.set_path([id])
and any callers) to use a non-built-in name like obj_id or resource_id; ensure
the function still constructs the controller via controller_class(), calls
controller.set_path([<new_name>]) and returns api, _, _ from
controller.create_api_and_tasks() with the new parameter name consistently
applied.

In `@tests/transports/tango/test_tango_util.py`:
- Around line 11-28: Rename the pytest parameter and local variable from id to
controller_id throughout this test file (update the parametrize decorators and
the test function signatures like test_accepts_valid_ids and
test_rejects_illegal_chars) to avoid shadowing the built-in, and change the
pytest.raises match argument to use re.escape(controller_id) instead of match=id
so literal strings like "bad.id" are matched literally; add an import for re at
top if missing and keep validate_tango_id referenced as-is.

---

Outside diff comments:
In `@src/fastcs/transports/rest/rest.py`:
- Around line 154-166: In _add_command_api_routes, the loop is incorrectly
iterating over root_controller_api.command_methods causing all command routes to
come from the root controller; change the iteration to use
controller_api.command_methods so each controller_api.walk_api() uses its own
commands (i.e., in function _add_command_api_routes replace
root_controller_api.command_methods with controller_api.command_methods when
building cmd_name and calling app.add_api_route with _wrap_command(method.fn)
and the existing PUT/204 options).

---

Nitpick comments:
In `@src/fastcs/launch.py`:
- Around line 35-39: The module-level _ENTRY_REGISTRY is never cleared and
accumulates classes when _build_entry_model (and thus launch or
_build_options_model) is called repeatedly; add a cleanup hook for tests by
exposing a clear function or resetting the registry from tests: implement a
small utility like clear_entry_registry() that sets _ENTRY_REGISTRY = {} (or
clears it in-place) and call that from your pytest fixture (e.g., in a
teardown/fixture that runs between tests) so repeated calls to
_build_entry_model/launch/_build_options_model won't cause unbounded growth.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2349537a-a589-43e3-a926-064097409116

📥 Commits

Reviewing files that changed from the base of the PR and between fb03263 and 74aa39b.

📒 Files selected for processing (74)
  • .vscode/launch.json
  • docs/conf.py
  • docs/how-to/launch-framework.md
  • docs/how-to/migrate-to-multi-controller.md
  • docs/how-to/multiple-transports.md
  • docs/snippets/dynamic.py
  • docs/snippets/static04.py
  • docs/snippets/static05.py
  • docs/snippets/static06.py
  • docs/snippets/static07.py
  • docs/snippets/static08.py
  • docs/snippets/static09.py
  • docs/snippets/static10.py
  • docs/snippets/static11.py
  • docs/snippets/static12.py
  • docs/snippets/static13.py
  • docs/snippets/static14.py
  • docs/snippets/static15.py
  • docs/tutorials/static-drivers.md
  • src/fastcs/control_system.py
  • src/fastcs/controllers/controller.py
  • src/fastcs/demo/controller.yaml
  • src/fastcs/demo/fastcs.yaml
  • src/fastcs/demo/schema.json
  • src/fastcs/demo/simulation/temp_controller.yaml
  • src/fastcs/launch.py
  • src/fastcs/transports/__init__.py
  • src/fastcs/transports/epics/__init__.py
  • src/fastcs/transports/epics/ca/ioc.py
  • src/fastcs/transports/epics/ca/transport.py
  • src/fastcs/transports/epics/ca/util.py
  • src/fastcs/transports/epics/docs.py
  • src/fastcs/transports/epics/emission.py
  • src/fastcs/transports/epics/gui.py
  • src/fastcs/transports/epics/options.py
  • src/fastcs/transports/epics/pva/ioc.py
  • src/fastcs/transports/epics/pva/transport.py
  • src/fastcs/transports/epics/pva/util.py
  • src/fastcs/transports/epics/util.py
  • src/fastcs/transports/graphql/graphql.py
  • src/fastcs/transports/graphql/transport.py
  • src/fastcs/transports/graphql/util.py
  • src/fastcs/transports/rest/rest.py
  • src/fastcs/transports/rest/transport.py
  • src/fastcs/transports/rest/util.py
  • src/fastcs/transports/tango/dsr.py
  • src/fastcs/transports/tango/options.py
  • src/fastcs/transports/tango/transport.py
  • src/fastcs/transports/tango/util.py
  • src/fastcs/transports/transport.py
  • tests/assertable_controller.py
  • tests/benchmarking/controller.py
  • tests/conftest.py
  • tests/data/config.yaml
  • tests/data/schema.json
  • tests/example_p4p_ioc.py
  • tests/example_softioc.py
  • tests/test_control_system.py
  • tests/test_launch.py
  • tests/test_multi_controller.py
  • tests/transports/epics/ca/test_ca_util.py
  • tests/transports/epics/ca/test_gui.py
  • tests/transports/epics/ca/test_initial_value.py
  • tests/transports/epics/ca/test_softioc.py
  • tests/transports/epics/pva/test_p4p.py
  • tests/transports/epics/pva/test_pva_gui.py
  • tests/transports/epics/pva/test_pva_util.py
  • tests/transports/epics/test_emission.py
  • tests/transports/epics/test_pv_prefix.py
  • tests/transports/graphQL/test_graphql.py
  • tests/transports/rest/test_id_validator.py
  • tests/transports/rest/test_rest.py
  • tests/transports/tango/test_dsr.py
  • tests/transports/tango/test_tango_util.py
💤 Files with no reviewable changes (4)
  • src/fastcs/demo/controller.yaml
  • src/fastcs/transports/epics/docs.py
  • src/fastcs/transports/tango/options.py
  • docs/conf.py

Comment thread src/fastcs/demo/schema.json
Comment thread src/fastcs/transports/__init__.py
Comment thread src/fastcs/transports/epics/util.py Outdated
Comment thread src/fastcs/transports/graphql/graphql.py Outdated
Comment thread src/fastcs/transports/graphql/transport.py Outdated
Comment thread src/fastcs/transports/tango/transport.py Outdated
Comment thread tests/test_multi_controller.py Outdated
Comment thread tests/transports/epics/pva/test_pva_util.py Outdated
Comment thread tests/transports/epics/test_emission.py Outdated
Comment thread tests/transports/tango/test_tango_util.py Outdated
gilesknap and others added 7 commits May 19, 2026 13:47
Local p4p IOC shutdown could exceed the 5s pytest-timeout, tripping
fixture teardown and leaving the subprocess (and its threads) alive,
which cascaded into Tango test errors. Fall back to SIGKILL after 2s
so teardown always completes promptly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`_add_command_api_routes` walked the controller tree but then registered
the root controller's commands at each visited path, instead of the
per-controller commands. This added phantom `/<sub>/<root-cmd>` routes
that aliased back to the root method, and left sub-controller commands
unrouted entirely.

Iterate `controller_api.command_methods` (matching `_add_attribute_api_routes`)
so commands are registered against the controller that owns them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`RestTransport.connect` guards `api.path[0]` with `if api.path:` so that
a controller without a seeded path is skipped instead of raising
IndexError before validation runs. The GraphQL and Tango variants
dereferenced `api.path[0]` unconditionally, leaving an inconsistent
edge: a misconfigured controller would crash with a confusing
IndexError in CA-or-PVA-style transports but be skipped cleanly in
REST.

Apply the same guard in both — GraphQL gates the `validate_graphql_id`
call, Tango skips the entry entirely so the `seen` collision map only
sees entries with a real id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`EpicsCAOptions`, `EpicsDocsOptions`, `EpicsGUIOptions` and
`EpicsPVAOptions` are pure-Python dataclasses with no EPICS runtime
dependency, but they shared a `try: ... except ImportError: pass`
block with `EpicsCATransport`. A missing softioc would suppress the
ImportError and silently drop the option classes from the public
`fastcs.transports` surface, breaking config-schema consumers that
don't care whether the CA runtime is available.

Import the option classes unconditionally and give each transport its
own try/except.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five places newly introduced for multi-controller routing assigned to a
local named `id`, shadowing Python's builtin and triggering Ruff A001/A002:

- `src/fastcs/transports/epics/util.py` validate_epics_pv_id
- `src/fastcs/transports/graphql/graphql.py` GraphQLServer query/mutation field naming
- `tests/transports/epics/pva/test_pva_util.py` parametrize args
- `tests/transports/epics/test_emission.py` `_api_with_id` helper
- `tests/transports/tango/test_tango_util.py` parametrize args

Rename the local in each to `name`. In `test_tango_util.py` also
`re.escape` the match string so chars like `.` aren't interpreted as
regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`test_fastcs_serves_two_controllers_end_to_end` cancelled the
`fastcs.serve` task and then slept 100ms before asserting
`controller.connected is False`. The sleep is non-deterministic — it
relies on the cancelled task's cleanup fitting inside that window — and
contributes flakiness if the event loop is busy.

Await the task directly so the assertions only run once teardown has
actually completed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new transport wrappers (`EpicsCATransport`, `EpicsPVATransport`,
`GraphQLTransport`, `RestTransport`, `TangoTransport`) and the new
empty option dataclasses (`EpicsCAOptions`, `EpicsPVAOptions`)
accepted unknown keys when Pydantic processed them from
`fastcs.yaml`, so a typo like `epicsca: { typo_key: oops }` slipped
through validation silently. The top-level `_build_options_model`
already sets `extra="forbid"`, but that doesn't propagate into nested
stdlib dataclasses.

Attach `__pydantic_config__ = ConfigDict(extra="forbid")` to each new
dataclass and regenerate `src/fastcs/demo/schema.json` and
`tests/data/schema.json`. Runtime validation now rejects unknown
sub-keys with a `Unexpected keyword argument` error, and the published
JSON schemas grow `additionalProperties: false` for the same nodes so
yaml-language-server flags typos in the editor too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gilesknap
Copy link
Copy Markdown
Contributor Author

@coderabbitai I've pushed fixes for your comments above. Verify them.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

I'll fetch my previous review comments on this PR to verify the fixes.


🏁 Script executed:

#!/bin/bash
# Fetch all review comments by coderabbitai on PR `#360`
gh pr view 360 --repo DiamondLightSource/fastcs --json reviews,comments

echo "---REVIEW COMMENTS---"
gh api repos/DiamondLightSource/fastcs/pulls/360/comments --paginate | jq '.[] | select(.user.login == "coderabbitai") | {id: .id, path: .path, line: .line, body: .body, created_at: .created_at}'

echo "---ISSUE COMMENTS---"
gh api repos/DiamondLightSource/fastcs/issues/360/comments --paginate | jq '.[] | select(.user.login == "coderabbitai") | {id: .id, body: .body, created_at: .created_at}'

Length of output: 115663


Let me check the current state of the flagged files to verify the fixes.


🏁 Script executed:

#!/bin/bash
echo "=== 1. REST rest.py: command_methods fix ==="
rg -n "command_methods" src/fastcs/transports/rest/rest.py

echo ""
echo "=== 2. epics/util.py: id -> controller_id rename ==="
rg -n "\bid\b" src/fastcs/transports/epics/util.py

echo ""
echo "=== 3. graphql/graphql.py: id -> controller_id rename ==="
rg -n "\bid\b" src/fastcs/transports/graphql/graphql.py

echo ""
echo "=== 4. graphql/transport.py: empty path guard ==="
cat src/fastcs/transports/graphql/transport.py

echo ""
echo "=== 5. tango/transport.py: empty path guard ==="
cat src/fastcs/transports/tango/transport.py

echo ""
echo "=== 6. test_multi_controller.py: sleep vs await ==="
rg -n "sleep\|cancel\|CancelledError" tests/test_multi_controller.py

echo ""
echo "=== 7. test_pva_util.py: id parameter ==="
cat tests/transports/epics/pva/test_pva_util.py

echo ""
echo "=== 8. test_emission.py: id parameter ==="
rg -n "def _api_with_id\|def .*\(id\|param.*id" tests/transports/epics/test_emission.py | head -20

echo ""
echo "=== 9. test_tango_util.py: id parameter ==="
cat tests/transports/tango/test_tango_util.py

echo ""
echo "=== 10. transports/__init__.py: EpicsCAOptions export ==="
cat src/fastcs/transports/__init__.py

echo ""
echo "=== 11. launch.py: _ENTRY_REGISTRY ==="
rg -n "_ENTRY_REGISTRY\|clear_entry_registry" src/fastcs/launch.py | head -15

Length of output: 7056


🏁 Script executed:

#!/bin/bash
echo "=== test_emission.py: _api_with_id function ==="
grep -n "def _api_with_id\|def .*id" tests/transports/epics/test_emission.py | head -20
echo "--- context around _api_with_id ---"
grep -n -A5 "def _api_with_id" tests/transports/epics/test_emission.py

echo ""
echo "=== launch.py: registry and model building ==="
rg -n "REGISTRY\|registry\|_build_entry" src/fastcs/launch.py | head -20

echo ""
echo "=== tango/transport.py: 'id =' variable ==="
grep -n "id =" src/fastcs/transports/tango/transport.py

echo ""
echo "=== test_multi_controller.py: end-to-end test cancellation ==="
grep -n -A20 "def test_fastcs_serves_two_controllers_end_to_end" tests/test_multi_controller.py | tail -30

Length of output: 1851


🏁 Script executed:

#!/bin/bash
echo "=== test_multi_controller.py: full end-to-end test ==="
grep -n -A50 "def test_fastcs_serves_two_controllers_end_to_end" tests/test_multi_controller.py

echo ""
echo "=== demo/schema.json: additionalProperties check ==="
python3 -c "
import json
with open('src/fastcs/demo/schema.json') as f:
    schema = json.load(f)
defs = schema.get('\$defs', {})
issues = []
for name, definition in defs.items():
    if definition.get('type') == 'object' and 'additionalProperties' not in definition:
        issues.append(name)
if issues:
    print('Missing additionalProperties: false in:', issues)
else:
    print('All object defs have additionalProperties set')
"

echo ""
echo "=== tango/transport.py: full connect method ==="
grep -n -A30 "def connect" src/fastcs/transports/tango/transport.py

Length of output: 3070


🏁 Script executed:

#!/bin/bash
echo "=== demo/schema.json: check specific new defs (EpicsCAOptions, transport wrappers) ==="
python3 -c "
import json
with open('src/fastcs/demo/schema.json') as f:
    schema = json.load(f)
defs = schema.get('\$defs', {})
# Check the new objects specifically mentioned in the review
new_objects = ['EpicsCAOptions', 'EpicsPVAOptions', 'EpicsCATransport', 'EpicsPVATransport', 'GraphQLTransport', 'RestTransport', 'TangoTransport', 'TemperatureControllerEntry']
for name in new_objects:
    if name in defs:
        d = defs[name]
        has_add_prop = 'additionalProperties' in d
        print(f'{name}: additionalProperties={d.get(\"additionalProperties\", \"MISSING\")}')
    else:
        print(f'{name}: NOT IN SCHEMA')
"

echo ""
echo "=== tango/transport.py: id variable (builtin shadowing) ==="
grep -n "^\s*id " src/fastcs/transports/tango/transport.py

Length of output: 543


@gilesknap: Thanks for pushing the fixes! Here's a summary of what I verified:

✅ All critical/actionable fixes confirmed:

Item Status
REST rest.py: command_methods bug (root → per-controller) ✅ Fixed
transports/__init__.py: EpicsCAOptions/EpicsPVAOptions unconditional export ✅ Fixed
epics/util.py: idname (no builtin shadowing) ✅ Fixed
graphql/graphql.py: id builtin shadowing ✅ Fixed
graphql/transport.py: empty path guard before validate_graphql_id ✅ Fixed
tango/transport.py: empty path guard (if not api.path: continue) ✅ Fixed
demo/schema.json: additionalProperties: false on all new objects ✅ Fixed
test_multi_controller.py: try: await task; except CancelledError: pass ✅ Fixed
test_pva_util.py, test_emission.py, test_tango_util.py: idname parameter ✅ Fixed

⚠️ One remaining minor item:

src/fastcs/transports/tango/transport.py Line 31 still has id = api.path[0] which shadows the Python builtin. This is a style nit — easy one-liner fix:

controller_id = api.path[0]

(with corresponding replacements of idcontroller_id on lines 32, 36, 37, 41)

ℹ️ _ENTRY_REGISTRY nitpick: No longer applicable — the registry no longer exists in the current launch.py implementation.

🐇 ~(∎ω∎)~

[resolve]

gilesknap and others added 2 commits May 19, 2026 14:42
The earlier `id` → `name` rename pass missed `TangoTransport.connect`'s
`id = api.path[0]` assignment, which still trips Ruff A001. CodeRabbit's
follow-up review flagged it.

Rename to `name` (not `controller_id`) to match the convention adopted in
`graphql/graphql.py`, `epics/util.py` and the renamed test parameters
from the earlier pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For consistency with the rest of the codebase — every other place that
reads the first path segment as the controller's id now calls that local
`name` (after the Ruff A001 pass on `epics/util.py`, `graphql/graphql.py`
and `tango/transport.py`).

Pure rename in `_coerce_pascal_name`, `emit_gui_files`, `emit_docs_files`
and `_render_index_md`; no behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gilesknap
Copy link
Copy Markdown
Contributor Author

gilesknap commented May 19, 2026

@coderabbitai final review before merge.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

@gilesknap: Triggering a final full review of the PR now!

✅ Actions performed

Full review triggered.

@gilesknap gilesknap merged commit c80a13c into main May 19, 2026
18 of 19 checks passed
@gilesknap gilesknap deleted the multiple-controllers branch May 19, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants