Skip to content

attributes: replace the DataType family with python types and *Meta typed dicts - #418

Open
coretl wants to merge 2 commits into
refactorfrom
refactor-issue-413
Open

attributes: replace the DataType family with python types and *Meta typed dicts#418
coretl wants to merge 2 commits into
refactorfrom
refactor-issue-413

Conversation

@coretl

@coretl coretl commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #413

An attribute's datatype is now the python type it holds, and everything that used to hang off a DataType instance — precision, units, limits, array shape — travels separately as metadata on attr.meta. This completes the DataType drop split out of #392, and absorbs the naming pass (#396 / ADR 0017) as that issue directed.

self.temperature = AttrRW(float, precision=3, units="degC", setter=apply_temp)

Scope

  • DataType is gone. datatype.py, _numeric.py, bool.py, float.py, int.py, string.py, enum.py, waveform.py and table.py are deleted. fastcs.datatypes now holds:
  • attr.meta stores the resolved metadata; attr.dtype is the python type. attr.datatype is gone, and description/group are meta fields with properties reading them.
  • Statically checked per datatype: AttrR/AttrW/AttrRW.__init__ are overloaded once per datatype with **meta: Unpack[*Meta], so AttrRW(str, precision=3) does not type check. validate_meta is the runtime counterpart — the error names the field, datatype and attribute ('precision' is not valid metadata for str attribute device_id), which is the message ControllerFiller — declarative/procedural split #394/Example 4 — SCPI device: annotated attributes + per-attribute filler data #405 need from the filler.
  • Naming pass (ADR 0017): precprecision; the flat min/max/min_alarm/max_alarm become NumericLimits(control=…, display=…, alarm=…, warning=…), all optional, with the ADR's inheritance rules (control inherits display, warning inherits alarm, warning ⊆ alarm asserted). Only the control range rejects a value; the rest are served to clients.
  • WaveformArray1D: AttrR(Array1D[np.int32], shape=(10,)) — the element type rides on the subscript. Arrays of rank > 1 keep working, written as AttrR(np.ndarray, array_dtype=np.uint16, shape=(1024, 1024)), since Array1D is by name one dimensional and there is no ophyd-async spelling for higher ranks.
  • Transport repoint (the widest part): EPICS CA (util.py, ioc.py), PVA (types.py, _pv_handlers.py, gui.py), the shared EPICS GUI, Tango, REST and GraphQL all dispatch on attr.dtype and read what they serve from attr.meta. add_update_datatype_callbackadd_update_meta_callback. The cast helpers now take the Attribute rather than a datatype object, since validation lives on the attribute.
  • Demo controllers, all 15 docs/snippets/*.py, and the prose docs (explanations/datatypes.md rewritten, how-to/table-waveform-data.md, explanations/transports.md, and the rest) migrated.

Instructions to reviewer on how to test:

  1. uv run --locked tox -e pre-commit,type-checking — both green.
  2. uv run pytest tests/test_datatypes.py tests/test_attributes.py -v
  3. python -m fastcs.demo run src/fastcs/demo/fastcs.yaml against the sim (tickit all src/fastcs/demo/simulation/temp_controller.yaml)

Checks for reviewer

  • Would the PR title make sense to a user on a set of release notes
  • Limit mapping. The old min_alarm/max_alarm drove EPICS LOPR/HOPR, which are EPICS' display range, not its alarm limits. With the categories now named, this PR maps displayLOPR/HOPR and controlDRVL/DRVH (and PVA/Tango likewise), so a driver that previously set min_alarm to get a display range now sets display. Serving alarm/warning as CA LOLO/HIHI/LOW/HIGH is left out — it needs the severity fields set too, and is a behaviour addition rather than this rename.
  • Array vs table at runtime. Both are held as np.ndarray; what separates them is that a table's metadata names its columns, so transports check for structured_dtype in attr.meta. Declaring Table without columns, or structured_dtype without Table, fails fast at construction.

Notes

  • Overload resolution has one honest gap. Overloads are tried in order and bool matches int while int matches float, so AttrR(bool, units=...) resolves to the int overload rather than failing statically; the constructor's runtime check rejects it. A call whose metadata is valid always picks its own datatype's overload (AttrR(bool) is AttrR[bool], AttrR(int) is AttrR[int]). This is commented at the overload block rather than left for a reader to discover.
  • DataType.all_equal was unused outside its own test and is not carried over.
  • tests/transports/epics/ca/test_initial_value.py had attributes literally named int/float/bool/str; harmless when the datatype was Int(), but they shadow the builtins in the class body now, so they are renamed *_rw (PV names follow).
  • 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), the same known limitation noted on 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/attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO #412. Excluding those two paths, pytest src tests --ignore=tests/benchmarking passes 361/371, with only the same 10 pre-existing p4p/socket-family failures — I confirmed those 10 are identical on refactor itself by running that file in a worktree off the base commit. Real CI covers docs and PVA.

Generated by Claude Code

…yped dicts

An attribute's datatype is now the python type it holds - `float`, an Enum
subclass, `Array1D[np.int32]`, `Table` - and everything that used to hang off a
`DataType` instance travels separately as metadata on `attr.meta`.

- New `fastcs.datatypes`: `Array1D`/`Table` datatype spellings, the per-datatype
  `*Meta` typed dicts (plus the superset `Meta`), nested `NumericLimits`, and the
  validation the `DataType` classes used to do.
- `Attr*` constructors are overloaded per datatype, so `AttrRW(str, precision=3)`
  is a static type error; `validate_meta` is the runtime counterpart for metadata
  that arrives without a static check.
- Naming pass (ADR 0017): `prec` -> `precision`, and the flat
  `min`/`max`/`min_alarm`/`max_alarm` become nested control/display/alarm/warning
  limits with inheritance.
- Every transport repointed from `attr.datatype.*` to `attr.dtype` + `attr.meta`;
  `add_update_datatype_callback` becomes `add_update_meta_callback`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G
@coderabbitai

coderabbitai Bot commented Aug 14, 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: f6fcc706-ca14-4e93-ad46-78daa5488b71

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

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.

The p4p enum test still read `attr.datatype.members`; it cannot run in the
sandbox (no PVA socket family), so CI was the first to see it. Three new
docstrings also referenced `ControllerFiller`, which does not exist until #394,
and an ambiguous `fastcs.datatypes.meta` - sphinx builds with warnings as
errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.26016% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.40%. Comparing base (e73453b) to head (c1fce75).
⚠️ Report is 2 commits behind head on refactor.

Files with missing lines Patch % Lines
src/fastcs/datatypes/validation.py 95.45% 4 Missing ⚠️
src/fastcs/transports/epics/pva/types.py 94.11% 4 Missing ⚠️
src/fastcs/datatypes/limits.py 91.66% 3 Missing ⚠️
src/fastcs/datatypes/types.py 92.85% 3 Missing ⚠️
src/fastcs/transports/tango/util.py 91.89% 3 Missing ⚠️
src/fastcs/attributes/_infer_datatype.py 81.81% 2 Missing ⚠️
src/fastcs/transports/epics/gui.py 94.28% 2 Missing ⚠️
src/fastcs/transports/epics/ca/util.py 99.01% 1 Missing ⚠️
src/fastcs/transports/epics/pva/gui.py 95.83% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           refactor     #418      +/-   ##
============================================
+ Coverage     91.25%   91.40%   +0.15%     
============================================
  Files            72       67       -5     
  Lines          2892     3003     +111     
============================================
+ Hits           2639     2745     +106     
- Misses          253      258       +5     

☔ 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 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.

This ended up looking pretty good; requesting a few changes, but won't iterate too much afterwards.

Comment on lines +126 to +139
Enum_T = TypeVar("Enum_T", bound=enum.Enum)
"""A TypeVar of any `enum.Enum` subclass an attribute can hold"""

Array_T = TypeVar("Array_T", bound=np.ndarray)
"""A TypeVar of any numpy array an attribute can hold"""


Inferred_T = TypeVar("Inferred_T", bound=DType)
"""A TypeVar of `DType` for the constructor overload that infers the datatype

Distinct from `DType_T` because the overload binds it from the getter or setter
in the same signature that annotates ``self``, and a class-scoped TypeVar cannot
be used there.
"""

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: this should be placed at the top with the other TypeVars

Comment on lines +22 to +27
def convert_datatype(dtype: type[DType]) -> type:
"""Converts a datatype to a rest serialisable type."""
match datatype:
case Waveform():
return list
case _:
return datatype.dtype
if issubclass(dtype, np.ndarray):
return list

return dtype

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 think this is also a bug fix as previously Table would've fallen into the return dtype instead of returning list as it is a subclass of np.ndarray

Comment on lines +115 to +145
if dtype is bool:
record = builder.boolIn(pv, ZNAM="False", ONAM="True", **common_fields)
elif dtype is int:
record = builder.longIn(
pv,
EGU=meta.get("units"),
**_display_limit_fields(meta),
**common_fields,
)
elif dtype is float:
record = builder.aIn(
pv,
EGU=meta.get("units"),
PREC=meta.get("precision", DEFAULT_PRECISION),
**_display_limit_fields(meta),
**common_fields,
)
elif dtype is str:
record = builder.longStringIn(pv, length=_string_length(meta), **common_fields)
elif issubclass(dtype, enum.Enum):
if len(enum_names(dtype)) > MBB_MAX_CHOICES:
record = builder.longStringIn(pv, **common_fields)
else:
common_fields.update(create_state_keys(dtype))
record = builder.mbbIn(pv, **common_fields)
elif issubclass(dtype, np.ndarray):
record = builder.WaveformIn(pv, length=_array_length(meta), **common_fields)
else:
raise FastCSError(f"EPICS unsupported datatype on {attribute}: {dtype}")

_mirror_meta_onto_record(attribute, record, _in_record_fields)

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: this used to be a match block, but is now a bunch of if.. elif. I think the match block was cleaner.

def _attribute_of_unsupported_datatype(mocker: MockerFixture):
attribute = mocker.MagicMock()
attribute.dtype = object
attribute.meta = {}

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: this does not need to be set.

Comment thread tests/test_datatypes.py
Comment on lines +87 to +89
_TABLE_META = Meta(
structured_dtype=[("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]
)

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: move this near the top of the page before all the tests.

Comment thread tests/test_datatypes.py
Comment on lines +152 to +156
def test_resolve_datatype_takes_an_enum_class():
class Colour(Enum):
RED = "red"

assert resolve_datatype(Colour) == (Colour, None)

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 is the same as the above test test_resolve_datatype but specific to enum. Instead, we can include this in the above test parameters, defingin class Colour(Enum) near the top of the page before all the tests.

Comment on lines +1 to +21
<display version="2.0.0">
<name>Demo Vector</name>
<x>0</x>
<y use_class="true">0</y>
<width>388</width>
<height>130</height>
<grid_step_x>4</grid_step_x>
<grid_step_y>4</grid_step_y>
<widget type="label" version="2.0.0">
<name>Title</name>
<class>TITLE</class>
<text>Demo Vector</text>
<x use_class="true">0</x>
<y use_class="true">0</y>
<width>388</width>
<height>25</height>
<font use_class="true">
<font name="Header 1" family="Liberation Sans" style="BOLD" size="22.0">
</font>
</font>
<foreground_color use_class="true">

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.

must: we shouldn't include the generated bob files in the repo. Add the opis dir into the .gitignore. Also, example_p4p_ioc should also be provided gui_options not just example_softioc

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