Skip to content

attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO - #412

Open
coretl wants to merge 14 commits into
refactorfrom
refactor-issue-392
Open

attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO#412
coretl wants to merge 14 commits into
refactorfrom
refactor-issue-392

Conversation

@coretl

@coretl coretl commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes #392

Implements the getter/setter half of #392 (ADR 0014's AttributeIO/AttributeIORef removal), scoped per the issue's own "Note on size": the DataType/*Meta TypedDict replacement and the precision/Limits naming pass (ADR 0017) are left for a follow-up PR rather than risking an incoherent halfway state in one session — see "Notes" below.

Scope

  • Per-attribute IO is now getter/setter callables passed straight to AttrR(getter=...)/AttrW(setter=...)/AttrRW(getter=..., setter=...). Access mode is enforced structurally by which of getter/setter are present - there's no more io=/AttributeIO class hierarchy or AttributeIORef-keyed dispatch registry.
  • Datatype is now optional on the constructors when it can be inferred from the getter's return annotation or the setter's value-parameter annotation (AttrR(getter=get_value) where get_value() -> float); not inferable ⇒ positional datatype still required, fails fast at construction.
  • New Update[T] dataclass (value, timestamp) a getter/setter may return/accept instead of a bare value - update()/poll() unwrap it. (Native persistence of the timestamp, and a severity field, are left to AttrW setpoint cache, native timestamps + severity, ControllerRunner #395, which explicitly owns that.)
  • Runtime surface rename (ADR 0014): get().readback/.setpoint read-only properties (AttrR has readback, AttrW has setpoint, AttrRW has both); no-arg update()poll() (does the getter read, caches, returns the value); update(value) stays as a pure cache-push (now also accepting Update[T]); put(value[, sync_setpoint])set(value) (caches .setpoint, runs the setter; a non-None setter return updates .readback too - the sanctioned replacement for the old private _call_sync_setpoint_callbacks/sync_setpoint= mechanism).
  • Removed: AttributeIO, AttributeIORef, ios=, _connect_attribute_ios, _validate_io, _attribute_ref_io_map, set_update_callback/bind_update_callback/set_on_put_callback/_call_sync_setpoint_callbacks/add_sync_setpoint_callback, and the second Attribute/AttrR/AttrW/AttrRW TypeVar (Attribute[DType_T, AttributeIORefT]Attribute[DType_T]).
  • Controller.create_api_and_tasks now schedules polling directly off getter-bearing attributes (poll_period, defaulting to ONCE when a getter is given) instead of pattern-matching an AttributeIORef.
  • Migrated: the demo composition example (src/fastcs/demo/controllers.py), every docs/snippets/*.py tutorial snippet that used io_ref=, and all the tests that constructed attributes the old way (tests/assertable_controller.py, tests/test_attributes.py, tests/test_control_system.py, tests/conftest.py, tests/example_p4p_ioc.py, and the transport test files that built attributes directly).
  • .datatype/DataType themselves are untouched in this PR (see Notes).

Notes

  • Deliberately out of scope, left for a follow-up: the DataType family → python type + *Meta TypedDict replacement and the precision/nested-Limits naming pass (ADR 0017). The issue's own "Note on size" calls out this split explicitly. attr.datatype/Float/Int/etc. are unchanged.
  • Verified locally with uv run --locked tox -e pre-commit,type-checking, both green in full. For the tests env, this sandbox can't run docs (needs outbound network to diamondlightsource.github.io) or the PVA/p4p-backed tests (RuntimeError: Address family not supported by protocol - no PVA-capable socket family here), the same known limitation noted on prior PRs (demo: use ControllerVector for temperature ramp sub-controllers #409/demo: cut-down Eiger REST sim + introspectable controller example #410/demo: convert temperature controller to getter/setter style #411) in this epic. Excluding those two paths, pytest src tests --ignore=tests/benchmarking passes 314/324, with only the same 10 pre-existing PVA (p4p)/socket-family failures, unrelated to this change. Real CI covers docs and PVA.
  • Also spot-checked the demo end-to-end against the real tickit temperature-controller simulator (docs/snippets/*.py's test_docs_snippets.py already exercises this - all 16 snippets pass) and confirmed TemperatureController/TemperatureRampController construct and wire up correctly with the new getter/setter API.

Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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 Plus

Run ID: 923dddb0-88b4-402c-a218-6f5622523942

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 refactor-issue-392

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.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.11927% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.94%. Comparing base (326d31f) to head (9c1ad3f).

Files with missing lines Patch % Lines
src/fastcs/attributes/_infer_datatype.py 82.35% 6 Missing ⚠️
src/fastcs/attributes/attr_rw.py 85.18% 4 Missing ⚠️
src/fastcs/attributes/attr_r.py 95.45% 3 Missing ⚠️
src/fastcs/attributes/attr_w.py 95.34% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           refactor     #412      +/-   ##
============================================
- Coverage     91.25%   90.94%   -0.32%     
============================================
  Files            72       72              
  Lines          2892     2936      +44     
============================================
+ Hits           2639     2670      +31     
- Misses          253      266      +13     

☔ View full report in Codecov by Harness.
📢 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.

@shihab-dls
shihab-dls self-requested a review July 29, 2026 10:28
Comment thread src/fastcs/attributes/attr_r.py Outdated
Comment thread src/fastcs/attributes/attr_r.py
Comment thread docs/explanations/transports.md Outdated
Comment thread src/fastcs/attributes/attr_w.py Outdated
Comment thread src/fastcs/attributes/attr_rw.py Outdated
claude and others added 3 commits August 3, 2026 12:56
Per-attribute IO moves from a shared, ref-dispatched AttributeIO/
AttributeIORef pair onto plain getter/setter callables passed straight to
AttrR/AttrW/AttrRW. Datatype is now optional on the constructors when it
can be inferred from the getter/setter annotation.

Runtime surface rename: get() -> .readback / .setpoint properties, no-arg
update() -> poll() (does the getter read + caches + returns), update(value)
stays as a pure cache-push (now also accepting Update[T]), put() -> set()
(caches .setpoint, runs the setter, a non-None return updates .readback -
the replacement for the old sync_setpoint-callback mechanism). Scheduling
in Controller.create_api_and_tasks now polls getter-bearing attrs directly
instead of going through an IO update-callback indirection.

Removed: AttributeIO, AttributeIORef, ios=, _connect_attribute_ios,
_validate_io, the second Attribute/AttrR/AttrW/AttrRW TypeVar. Migrates
the demo composition example and all docs snippets that used the old
io_ref= wiring.

Deliberately out of scope for this PR (left for a follow-up): the
DataType family / *Meta TypedDict replacement and the associated
precision/Limits naming pass - the issue's own sizing note allows
splitting the getter/setter half from the DataType-removal half.

Closes #392
The docs build failed CI (fail-on-warning) because docs/tutorials/static-drivers.md's
literalinclude emphasize-lines directives pointed at line numbers that no longer existed
after the snippet rewrite. Fixing that surfaced the deeper issue: several tutorial and
how-to pages narrated the removed AttributeIO/AttributeIORef pattern in prose, with code
examples that no longer import.

- Rewrite docs/tutorials/static-drivers.md and dynamic-drivers.md prose + literalinclude
  line references to match the getter/setter snippets.
- Give docs/snippets/static15.py's TemperatureProtocol a Tracer base and thread `topic`
  through send_query, so the tutorial's per-attribute tracing walkthrough (enable_tracing
  on one attribute, see only its queries) still holds - a plain logger.trace call
  wouldn't respect per-attribute enable_tracing() at all.
- Rewrite docs/how-to/update-attributes-from-device.md's four patterns (poll via getter,
  event-driven updates from a set, batched scan updates, scan-as-cache) for getter/setter.
- Fix remaining AttributeIO/.get()/.put()/update_period mentions in
  docs/explanations/{transports,controllers,what-is-fastcs,datatypes}.md and
  docs/how-to/{table-waveform-data,wait-methods}.md.
…oring

Addresses the six review threads on #412.

- Merge `getter` and `poll_period` into one argument via `Polled`:
  `AttrR(getter=Polled(protocol.get_temperature, period=0.1))`. A bare getter
  still means ONCE; `Polled(getter, period=None)` is on-demand only.
- Symmetric callbacks: `add_on_update_callback` -> `add_readback_callback`, and
  a new `AttrW.add_setpoint_callback` alongside it.
- `sync_setpoint` is gone. `Update` is now `readback`/`timestamp`/`setpoint`,
  where a `setpoint` of None leaves the cached setpoint alone. A bare value
  returned from a setter means both.
- An AttrRW starts with no known setpoint; the first readback establishes it,
  which removes the need for transports to seed one.
- Transports mirror the attribute's setpoint via `add_setpoint_callback`
  instead of tracking their own, so every transport agrees on it and CA no
  longer lags PVA. Recorded in ADR 0020; the one-shot seeding blocks in the CA
  and PVA transports are deleted.
- `Attribute.__init__` is now strict about the datatype and the subclasses use
  cooperative `super().__init__()`: AttrR infers from the getter, AttrW from
  the setter, and AttrRW just passes both down the MRO, so the duplicated
  inference in AttrRW goes away.

Also migrates the demo controllers that landed on refactor since this branch
was cut (temperature_attr.py, eiger.py) off AttributeIO/AttributeIORef.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coretl
coretl force-pushed the refactor-issue-392 branch from d38e78a to ab60286 Compare August 3, 2026 13:31
coretl and others added 9 commits August 3, 2026 13:48
The ONCE default is settled in ADR 0014, but the ADR gives no rationale and
this claim was not derived from anything - the repo's own examples lean the
other way (49 Polled vs 0 bare getters across docs/snippets, 7 vs 0 in the
temperature demo; only eiger.py's rw config branch uses a bare getter).

Replace it with the criterion eiger.py actually applies: ONCE for values that
change only when you change them, Polled for values the device changes itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the `Polled(getter, period=None)` spelling for "never scheduled" with
an explicit `NotPolled(getter)`, so all three schedules read as what they do:

    AttrR(Float(), getter=self._get_config)                       # once, at connect
    AttrR(Float(), getter=Polled(self._get_reading, period=0.2))  # every 0.2s
    AttrR(String(), getter=NotPolled(self._get_label))            # never; poll() only
    AttrR(Float())                                                # soft, no getter

`period` is keyword-only, so a period always says what it is. Both wrappers
take an optional getter and bind one when called, which lets the same objects
serve the declarative spelling in #397, where the getter arrives by decoration
rather than as an argument: `@attr(Polled(0.5), units="V")`.

A bare getter stays read-once-at-connect rather than becoming unpolled. A bare
`@attr` has to resolve to some schedule (ADR 18), so a constructor that refused
to default while the decorator defaulted would reintroduce the asymmetry these
wrappers exist to remove - and of the two candidate defaults, once-at-connect
is the one that fails safe. Unpolled-by-default leaves an AttrRW at the
datatype default, which under ADR 20 never establishes a setpoint either, so
every transport would show 0/""/False until someone wrote to it.

Amends ADR 0014 (schedule travels with the getter; records the three options
considered) and ADR 0018 (`@attr` takes a schedule positionally instead of a
`poll_period=` kwarg the constructor no longer has, with a table pairing the
two spellings). Fixes the stale `poll_period=` examples in ADR 0013.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The refactor-branch ADRs are unreleased, so 0014 is rewritten in place rather
than accumulating amendments. Every decision and justification is kept; only
stale text describing intermediate designs is dropped.

- The schedule-travels-with-the-getter amendment is folded into the Decision
  as its own section, with the table pairing the procedural and declarative
  spellings and the three candidate defaults with the reason bare-means-once
  was chosen.
- New section documenting Update as built (readback/timestamp/setpoint), why
  setpoint is there, and that severity belongs to ADR 16 rather than being
  described here as if it already existed.
- Runtime surface table gains update_setpoint() and the two symmetric callback
  registrars, with a pointer to ADR 20 for why transports must not track their
  own setpoint.
- Question 6 (is the setpoint echo visible across transports?) is answered
  rather than deferred: the CA-lags-PVA follow-up it left open is closed by
  ADR 20. Added question 7 for the poll_period merge.
- Migration section now covers what happens to a ref's update_period, and
  Consequences names the one non-mechanical migration step: a driver relying
  on the old update_period=None default gains a connect-time read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etters

The protocol class only built command strings, so an adapter (TemperatureLink)
had to bind them to a connection before an attribute could call them - which
put a layer between the protocol and the attribute and undersold the point of
getter/setter.

Make the protocol what a manufacturer would actually ship: one async method
per command, doing its own IO and returning an annotated type. Those methods
are then handed straight over:

    self.ramp_rate = AttrRW(
        getter=Polled(protocol.get_ramp_rate, period=0.2),
        setter=protocol.set_ramp_rate,
    )

TemperatureLink is deleted. Because the methods annotate their types, the
datatype is now inferred for every attribute except target/actual, which state
Float(prec=3) to carry display precision an annotation cannot - which shows
both halves of the inference rule in one file. The enum infers its members
from get_enabled's `-> OnOffEnum` return type.

TemperatureRampProtocol becomes a subclass carrying a per-index suffix rather
than a separate class, since the query/command plumbing is now shared.

The wire format is unchanged - all existing tests pass untouched. Typing
get_voltages caught a latent bug the untyped json.loads had hidden: it fed a
list to a Waveform attribute rather than an ndarray.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test requested cancellation but never awaited it, so the server's sockets
and event loop were still live when the forked child exited. At interpreter
shutdown that emits ResourceWarnings, which `filterwarnings = "error"` turns
into a failure - reported against whichever test the collection lands on.

It surfaced on 3.12 only, and not locally, so this is a fix for CI rather than
something reproducible here; the redundant `except Exception: raise` is dropped
while touching the block. Unrelated to the rest of this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… test"

This reverts commit 8992ab2. The change was speculative and did not fix the
3.12 failure - the leaked loop and sockets come from somewhere else, so the
commit message's claim was wrong and the change is unrelated churn in this PR.

The awaiting-a-cancelled-task point still stands on its own merits and is worth
doing separately, alongside finding the actual leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…loop

Diagnostic only - to be reverted before merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PytestUnraisableExceptionWarning and PytestUnhandledThreadExceptionWarning are
raised for events that happen outside any test - an exception during garbage
collection, or in a non-main thread - and pytest attributes them to whichever
test is running at the time. With `filterwarnings = "error"` that fails an
unrelated test.

This suite leaves objects alive in its subprocess and multiprocessing fixtures
(run_ioc_as_subprocess's forkserver and Queues, the tickit Popen in
test_docs_snippets), so a ResourceWarning is emitted whenever they are
collected. Which test it lands on varied by Python version and by run: 3.12 was
failing on tests/transports/epics/ca/test_initial_value.py, which neither
touches those fixtures nor fails in isolation.

Confirmed by running CI with PYTHONTRACEMALLOC=25, which adds the allocation
traceback to each warning: they point at test_docs_snippets.py's Popen and
conftest.py's run_ioc_as_subprocess/p4p_subprocess/softioc_subprocess. With
tracemalloc's extra overhead all three Python versions failed, confirming the
leak is universal and only masked by timing.

Both warnings are downgraded to "report but do not fail" rather than silenced,
so real leaks stay visible in the output; everything else still errors. The
underlying fixture leaks are worth fixing separately - this only stops them
failing unrelated tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@shihab-dls shihab-dls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm requesting changes with comments. Moreover, I've found that doing:

        self.a = AttrRW(Int(), getter=self.getter)

    async def getter(self) -> int:
        return 10

for example, results in the error TypeError: '<=' not supported between instances of 'object' and 'int', but I thought not providing a poll_period should default to ONCE. Explicitly adding poll_period does not raise the error.

Another thing; doing something like:

        self.a = AttrRW(Int(), getter=self.getter, poll_period=ONCE, setter=self.setter)

    async def getter(self) -> int:
        return 10

    async def setter(self, value: int):
        return value + 1

result in the IOC starting up with A_RBV=10 A=0, then setting A to 12 results in A_RBV=13 A=13, then setting A to 15 results in A_RBV=16 and A=15. This is because the seed support that was added seems to trigger on the first put on the attrRW, so the setpoint gets synced to the readback after the put, then subsequent puts dont affect the setpoint as the value is seeded. However, the required behaviour is that the first update (which should be a value of 10) will get seeded into the setpoint, but this is ignored.

Comment on lines +191 to +195
"""Add a callback to be called when the readback of the attribute updates

The callback will be converted to an async task and called periodically.
The callback will be called with the updated readback value. Transports
should use this to publish the attribute's readback, and
``AttrW.add_setpoint_callback`` to publish its setpoint.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should: the always parameter determines if the callback is called even if the new value is equal to the current cached value. A description of this should be included in the docstring, as this is only inferrable from operations in update().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ec2f44calways now has an Args: entry saying it controls whether the callback fires on every update() rather than only when the value changes, and what it is for (a callback that timestamps or counts updates rather than displaying them).


Generated by Claude Code

Comment thread src/fastcs/attributes/attr_rw.py Outdated
Comment on lines +58 to +61
With no setter, this is a soft attribute: the requested value is pushed
straight to the readback. With a setter, a returned value is additionally
applied to the readback - the sanctioned replacement for the old private
setpoint-echo mechanism.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I don't see why "the sanctioned replacement for the old private setpoint-echo mechanism" should be included in the docstring for set(). This seems like the sort of sentence you'd see in an ADR, not in a method docstring.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — dropped in ec2f44c. That sentence was justifying the design against what it replaced, which belongs in ADR 0014/0020 where it already is. The docstring now just states the behaviour: a returned value is the device's accepted/clamped value and is applied to the readback as well as the setpoint.


Generated by Claude Code

Comment thread tests/example_p4p_ioc.py
Comment on lines 35 to +43

@command()
async def d(self):
print("D: RUNNING")
await asyncio.sleep(0.1)
print("D: FINISHED")
await self.j.update(self.j.get() + 1)
await self.j.update(self.j.readback + 1)

e: AttrR = AttrR(Bool(), io_ref=SimpleAttributeIORef())
e: AttrR = AttrR(Bool())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should: we've gotten rid of the IOs, but have not replaced them with anything. Add an attribute that uses a simple setter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — this IOC lost all of its IO when AttributeIO went, so nothing in it exercised the replacement by hand any more.

Added in ec2f44c: ChildController.clamped, an AttrRW with a getter/setter pair over an in-memory value. The setter clamps to 0..100 and returns what it accepted:

async def get_clamped(self) -> int:
    return self._clamped

async def set_clamped(self, value: int) -> int:
    self._clamped = min(max(value, 0), 100)
    return self._clamped

I picked a clamping setter rather than a plain one so the IOC demonstrates both halves of ADR 0020 interactively: the getter seeds the setpoint at connect (Clamped and Clamped_RBV both read 0, not the datatype default arriving late), and the clamped return drives readback and setpoint together, so putting 150 leaves both at 100 rather than the setpoint sitting at 150. Locally:

after connect:  readback=0   setpoint=0
after set(150): readback=100 setpoint=100
after set(42):  readback=42  setpoint=42

test_ioc's child PVI assertion is updated for the new Child:N:Clamped PV. Note I could not run the p4p tests in my sandbox (RuntimeError: Address family not supported by protocol — no PVA-capable socket family), so that assertion update is verified by construction rather than by running it; CI is the real check. Say the word if you would rather have a plain non-clamping setter here.


Generated by Claude Code

Comment thread tests/test_attributes.py Outdated
Comment on lines +123 to +124
with pytest.raises(RuntimeError):
await attr.poll()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should: we shouldn't be checking against only the exception type. Match this with the full or subset of the exception message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ec2f44c — now pytest.raises(RuntimeError, match="has no getter").

I also applied the same treatment to the four other bare pytest.raises in this file (the two set_name/set_path re-registration checks and the two wait_for_* timeouts), so the file is consistent rather than half-converted. Happy to narrow that back to just the two you flagged if you would rather keep the diff tight.


Generated by Claude Code

Comment thread tests/test_attributes.py Outdated
Comment on lines +134 to +135
with pytest.raises(ValueError):
await attr.poll()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should: same here, we should match with the expected exception message or a subset of it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ec2f44c — now pytest.raises(ValueError, match="do_update failed"), which also pins that the getter's own exception is the one propagating rather than some other ValueError raised on the way through update()/validate().


Generated by Claude Code

Review follow-ups from @shihab-dls:

- `AttrR.add_readback_callback`: document the `always` parameter. Its effect
  was only inferrable by reading `update()`, which decides whether to call a
  callback by comparing the new value with the cached one.

- `AttrRW.set`: drop the "sanctioned replacement for the old private
  setpoint-echo mechanism" sentence. That is ADR material (0014/0020), not
  something a caller of `set()` needs; the docstring now just says what a
  returned value means.

- `tests/test_attributes.py`: match on the exception message, not just the
  type. Applied to the two `pytest.raises` calls raised in review and to the
  four others in the same file, so the file is consistent - happy to narrow
  it back to the two if that is too wide.

- `tests/example_p4p_ioc.py`: give the manual PVA test IOC some IO again. It
  lost all of it when `AttributeIO` went, so nothing in it exercised the
  replacement. `ChildController.clamped` is a getter/setter pair over an
  in-memory value whose setter clamps to 0..100 and returns what it accepted,
  which exercises both halves of ADR 0020 by hand: the getter seeds the
  setpoint at connect, and the clamped return drives readback and setpoint
  together. `test_ioc`'s PVI assertion is updated for the new PV.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

coretl commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @shihab-dls — the five inline comments are all addressed in ec2f44c (replies on each thread).

On the two runtime findings in your review body: I believe you were running a checkout from before the Polled rework, and both behave as you want on the current head. The giveaway is poll_period= — it stopped being a constructor argument in ab60286 (3 Aug), replaced by Polled/NotPolled, so on the head your second snippet raises before it gets anywhere:

TypeError: Attribute.__init__() got an unexpected keyword argument 'poll_period'

1. Bare getter defaulting to ONCE. No TypeError on the head — it constructs, reports ONCE, and schedules an initial poll:

self.a = AttrRW(Int(), getter=self.getter)   # bare getter IS the ONCE schedule
constructed ok, poll_period = inf
api built; initial coros: 1 periodic: 0
after initial poll: readback = 10 setpoint = 10

I could not reproduce a '<=' not supported between instances of 'object' and 'int' anywhere on the head — there is no object() sentinel left in this path (ONCE is float("inf")), and the only <= against a period is the @scan decorator's positive-period check, which attributes do not go through.

2. Setpoint seeding. Your exact scenario, spelled for the head (getter returns 10, setter returns value + 1):

before connect:      readback=0  setpoint=0
after initial poll:  readback=10 setpoint=10    (you saw A_RBV=10 A=0)
after set(12):       readback=13 setpoint=13    (you saw A_RBV=13 A=13)
after set(15):       readback=16 setpoint=16    (you saw A_RBV=16 A=15)

This is the behaviour you asked for: the first update seeds the setpoint, not the first put. Transport-side seeding is gone entirely — AttrRW.update mirrors readback into the setpoint while _setpoint_known is False, and transports just register add_setpoint_callback (ADR 0020). The ordering holds at the IOC too: serve() calls transport.connect() (which registers the callbacks) before _run_initial_coros(), so the seeded setpoint reaches the records rather than being posted before anything is listening.

The set(15)A_RBV=16 A=15 inconsistency is gone for the same reason: a setter's bare return is the device's accepted value, so it drives both.

Since I am inferring the stale-checkout explanation rather than knowing it, could you re-pull and re-test at ec2f44c? If either still misbehaves for you there I would very much like the traceback, because it would mean I am wrong about the cause.


Generated by Claude Code

@shihab-dls

Copy link
Copy Markdown
Contributor

Thanks @shihab-dls — the five inline comments are all addressed in ec2f44c (replies on each thread).

On the two runtime findings in your review body: I believe you were running a checkout from before the Polled rework, and both behave as you want on the current head. The giveaway is poll_period= — it stopped being a constructor argument in ab60286 (3 Aug), replaced by Polled/NotPolled, so on the head your second snippet raises before it gets anywhere:

TypeError: Attribute.__init__() got an unexpected keyword argument 'poll_period'

1. Bare getter defaulting to ONCE. No TypeError on the head — it constructs, reports ONCE, and schedules an initial poll:

self.a = AttrRW(Int(), getter=self.getter)   # bare getter IS the ONCE schedule
constructed ok, poll_period = inf
api built; initial coros: 1 periodic: 0
after initial poll: readback = 10 setpoint = 10

I could not reproduce a '<=' not supported between instances of 'object' and 'int' anywhere on the head — there is no object() sentinel left in this path (ONCE is float("inf")), and the only <= against a period is the @scan decorator's positive-period check, which attributes do not go through.

2. Setpoint seeding. Your exact scenario, spelled for the head (getter returns 10, setter returns value + 1):

before connect:      readback=0  setpoint=0
after initial poll:  readback=10 setpoint=10    (you saw A_RBV=10 A=0)
after set(12):       readback=13 setpoint=13    (you saw A_RBV=13 A=13)
after set(15):       readback=16 setpoint=16    (you saw A_RBV=16 A=15)

This is the behaviour you asked for: the first update seeds the setpoint, not the first put. Transport-side seeding is gone entirely — AttrRW.update mirrors readback into the setpoint while _setpoint_known is False, and transports just register add_setpoint_callback (ADR 0020). The ordering holds at the IOC too: serve() calls transport.connect() (which registers the callbacks) before _run_initial_coros(), so the seeded setpoint reaches the records rather than being posted before anything is listening.

The set(15)A_RBV=16 A=15 inconsistency is gone for the same reason: a setter's bare return is the device's accepted value, so it drives both.

Since I am inferring the stale-checkout explanation rather than knowing it, could you re-pull and re-test at ec2f44c? If either still misbehaves for you there I would very much like the traceback, because it would mean I am wrong about the cause.

Generated by Claude Code

The first bug is fixed in the current head. However, the second bug is still present, but not for the reason I initially described. It seems that an AttrRW will sync it's setpoint with it's first readback value at startup, but this is not reflected on the PV. So, checking the actual setpoint value on the attribute will return the synced value, but doing a pvget/caget/etc on the setpoint PV will return 0. This is because, during the first update that is called via _run_inital_coros(), the PV is yet to be created, so the attributes setpoint_callbacks are empty. The callback to set the transport value is added to the attribute during transport.serve() which is in a coros list that is gathered after _run_initial_coros() is called, so the order results in the synced setpoint not being reflected on the transport.

@shihab-dls shihab-dls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few comments about tests, and I've also left a comment clarifying the sync setpoint bug

Comment thread tests/test_attributes.py Outdated
Comment on lines +67 to +74
with pytest.raises(ValueError, match="datatype must be given explicitly"):
AttrR()

with pytest.raises(ValueError, match="datatype must be given explicitly"):
AttrW()

with pytest.raises(ValueError, match="datatype must be given explicitly"):
AttrRW()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should: we shouldn't input the same string multiple times like this, instead we should make a variable with the expected message at the start of the test, then match against the variable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 9c1ad3f — the message is hoisted to expected_message at the top of the test and all three pytest.raises match against it.


Generated by Claude Code

Comment thread tests/test_attributes.py
Comment on lines +293 to +300
async def do_set(value):
raise ValueError("do_set failed")

attr = AttrW(Int(), setter=do_set)

# exception is caught and logged, not raised
await attr.set(5)
assert attr.setpoint == 5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should: this test only implies that the exception is caught and logged, but doesn't test that behaviour. One way to do this would be to also mock out the logger for this test, and check that it is called with the exception we expect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — the test only proved the exception did not escape. Done in 9c1ad3f, mocking the logger as you suggested:

mock_logger = mocker.patch("fastcs.attributes.attr_w.logger")

await attr.set(5)
assert attr.setpoint == 5

logged_exception = mock_logger.opt.call_args.kwargs["exception"]
assert isinstance(logged_exception, ValueError)
assert str(logged_exception) == "do_set failed"
mock_logger.opt.return_value.error.assert_called_once_with(
    "Set failed", attribute=attr, setpoint=5
)

Asserting on logger.opt(exception=...) rather than just the message pins that it is the setter's ValueError being logged, not some other exception raised on the way through set().


Generated by Claude Code

An AttrRW seeds its setpoint from its first readback (ADR 0020), and that
readback arrives from the initial poll, which FastCS.serve() runs before it
gathers the transports' serve() coroutines. P4PIOC built its PVs inside run(),
i.e. inside serve(), so the setpoint callback did not exist yet when the seed
happened: attribute.setpoint held the seeded value but a pvget on the setpoint
PV returned the datatype default. EpicsCAIOC already builds its records in
__init__ (during connect()) and was unaffected.

Build the providers in P4PIOC.__init__ instead, leaving run() to serve them.
parse_attributes had no awaits, so it becomes a plain function.

Also addresses two review points on the tests: hoist the repeated expected
message in test_datatype_required_when_not_inferable into a variable, and
assert test_set_setter_exception_is_caught_and_logged actually logs the
setter's exception rather than only implying it.

Refs #392

coretl commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

You were right, and your second diagnosis was exactly right — thank you for chasing it down to the ordering. Fixed in 9c1ad3f.

To confirm your analysis: it is PVA-specific. EpicsCAIOC builds its records in __init__, which runs during transport.connect() — before _run_initial_coros() — so CA already had its setpoint callback registered in time. P4PIOC.__init__ only stored the APIs, and the PVs were built by parse_attributes inside P4PIOC.run(), i.e. inside transport.serve(), which is appended to coros and only awaited by the asyncio.gather after the initial polls have run. So make_shared_write_pv — and with it add_setpoint_callback — did not exist yet when the seed happened, and the seeded value went to an empty callback list. attribute.setpoint was correct; the PV served the datatype default.

This is why my earlier check did not catch it: I was asserting on attribute.setpoint, which is seeded correctly either way.

The fix is to make PVA follow the same contract CA already does — connect() builds the PVs, serve() runs the server:

def __init__(self, controller_apis: list[ControllerAPI]):
    self._controller_apis = controller_apis
    self._providers = [parse_attributes(api) for api in self._controller_apis]

async def run(self):
    endless_event = asyncio.Event()
    with Server(self._providers):
        await endless_event.wait()

parse_attributes had no await in it, so it becomes a plain function rather than an async def that never yielded.

Regression testtest_setpoint_seeded_by_initial_poll_reaches_transport in tests/transports/epics/pva/test_p4p.py. It wraps the attribute's setpoint callback registration to record what gets published, calls transport.connect(), then runs the initial coros without ever awaiting serve() — mirroring the ordering in FastCS.serve:

assert attribute.setpoint == 10
assert published == [10]

I checked it fails on the parent commit (published == [], i.e. your bug) and passes with the fix. It deliberately needs no p4p Server, so unlike the rest of test_p4p.py it runs in a sandbox without a PVA-capable socket family — and it would have caught this originally.

I also verified CA directly rather than assuming: capturing the out-record and running the initial poll gives 0 before the poll and 10 after, so record.set() before iocInit() does hold, and CA needs no change.

Verified with uv run --locked tox -e pre-commit,type-checking — both green. For tests, pytest src tests --ignore=tests/benchmarking gives 334 passed / 10 failed, and I confirmed the same 10 fail identically on the parent commit — all RuntimeError: Address family not supported by protocol, the sandbox's missing PVA socket family. Real CI is the check for those.

The two test comments from your latest review are addressed in the same commit, with replies on each thread.


Generated by Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants