Skip to content

Multi-controller foundation: Controller.id, multi-class launch, unified transports, REST per-id (#353)#360

Draft
gilesknap wants to merge 12 commits intomainfrom
multiple-controllers
Draft

Multi-controller foundation: Controller.id, multi-class launch, unified transports, REST per-id (#353)#360
gilesknap wants to merge 12 commits intomainfrom
multiple-controllers

Conversation

@gilesknap
Copy link
Copy Markdown
Contributor

@gilesknap gilesknap commented May 5, 2026

Fixes #353
Fixes #354
Fixes #355
Fixes #356

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. All four 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.

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 6d7ccfcc Wire FastCS multi-controller end-to-end (REST)

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

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

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

# Commit Headline
10 504d5671 EPICS PVA multi-root with N PVI roots
11 cc24439a Use literal markup for P4PIOC docstring refs

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

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

Subsequent slices (#357 Tango, #358 GUI/docs, #359 demo+rename) layer on top.

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/

Checks for reviewer

  • Would the PR title make sense to a user on a set of release notes

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 (id-keyed controllers: YAML, multi-class launch(), controller_apis plural accessor, Controller.id-seeded paths) 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:controllers: keyed by id, type: discriminator), the Controller.id / path semantics, the unified Transport.connect(list[ControllerAPI]) signature, and the EPICS CA prefix derivation (pv_prefix_from_path).

🤖 Generated with Claude Code

gilesknap and others added 7 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>
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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa2a67d1-edbb-4a8f-99ca-fb4dbd4eddca

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

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

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 97.56098% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.57%. Comparing base (fb03263) to head (b8a7961).

Files with missing lines Patch % Lines
src/fastcs/launch.py 96.07% 2 Missing ⚠️
src/fastcs/control_system.py 96.42% 1 Missing ⚠️
src/fastcs/transports/graphql/graphql.py 94.73% 1 Missing ⚠️
src/fastcs/transports/graphql/util.py 85.71% 1 Missing ⚠️
src/fastcs/transports/transport.py 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #360      +/-   ##
==========================================
+ Coverage   91.24%   91.57%   +0.33%     
==========================================
  Files          70       72       +2     
  Lines        2604     2743     +139     
==========================================
+ Hits         2376     2512     +136     
- Misses        228      231       +3     

☔ 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
gilesknap and others added 5 commits May 5, 2026 15:38
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.
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant