From d6b076d811cb51b8f72a8a324f9448c28b9cc443 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Tue, 20 Oct 2026 21:26:46 +0100 Subject: [PATCH 1/3] fix: restrict missing component attributes to declared IO fields --- docs/usage/key-concepts.md | 10 ++++ plugboard/component/component.py | 13 +++++- tests/unit/test_component.py | 78 ++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/docs/usage/key-concepts.md b/docs/usage/key-concepts.md index 06980fdc..b98b425d 100644 --- a/docs/usage/key-concepts.md +++ b/docs/usage/key-concepts.md @@ -21,6 +21,16 @@ When implementing your own components, you will need to: * Optionally define an `init()` method to do any required preparatory steps before the model in run. * In the case of event based models, define custom [`Event`][plugboard.events.Event] subclasses and corresponding event handler methods decorated with `Event.handler`. +Input and output fields can be accessed as component attributes. A declared public +field that has not been assigned a value returns `None`, and `hasattr` returns +`True` for it. Reading the field does not assign a value or make an input ready. +Fields declared at runtime, such as `FileReader` outputs supplied through +`field_names`, follow the same rules using the instance's current `io` controller. +Ordinary attributes retain normal Python behavior: reading an unknown attribute +raises `AttributeError` with the component and field names, and `hasattr` returns +`False`. Assigning `None` explicitly stores it as the field's value; an unset field +has no entry in the instance dictionary. + ### Connectors Data flows between components via **Connectors**. You will need to use these to tell Plugboard how data should flow between your components. Your overall model might be very simple, for example: diff --git a/plugboard/component/component.py b/plugboard/component/component.py index af51a2eb..794bd9a6 100644 --- a/plugboard/component/component.py +++ b/plugboard/component/component.py @@ -211,9 +211,18 @@ def _get_component_bases(cls) -> list[_t.Type[Component]]: # Prevents type-checker errors on public component IO attributes def __getattr__(self, key: str) -> _t.Any: + """Return `None` for unset declared public I/O fields only. + + Use the current controller so fields declared at runtime are supported. + An unset field is not added to the instance dictionary, distinguishing it + from a field explicitly assigned `None`. + """ if not key.startswith("_"): - return None - raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'") + io = object.__getattribute__(self, "io") + if key in io.inputs or key in io.outputs: + return None + name = object.__getattribute__(self, "__dict__").get("name", "") + raise AttributeError(f"{type(self).__name__} component '{name}' has no attribute '{key}'") def __setattr__(self, key: str, value: _t.Any) -> None: """Sets attributes on the component. diff --git a/tests/unit/test_component.py b/tests/unit/test_component.py index 9c43bafb..c84ad141 100644 --- a/tests/unit/test_component.py +++ b/tests/unit/test_component.py @@ -1,12 +1,15 @@ """Unit tests for `Component`.""" # ruff: noqa: D101,D102,D103 +import copy +from pathlib import Path import typing as _t import pytest from plugboard.component import Component, IOController as IO from plugboard.connector import AsyncioConnector +from plugboard.library.file_io import FileReader, FileWriter from plugboard.schemas import ComponentArgsDict, ConnectorSpec, Status @@ -34,6 +37,81 @@ async def step(self) -> None: await self.io.close() +@pytest.mark.parametrize("field", ["a", "b", "c"]) +def test_unset_declared_field(field: str) -> None: + component = A(name="static") + assert getattr(component, field) is None + assert hasattr(component, field) + assert field not in vars(component) + assert component._field_inputs == {} + assert not component._field_inputs_ready + + setattr(component, field, None) + assert getattr(component, field) is None + assert field in vars(component) + if field in component.io.inputs: + assert field in component._field_inputs + + +@pytest.mark.parametrize("field", ["misspelled", "_missing", "__setstate__"]) +def test_unknown_attribute(field: str) -> None: + component = A(name="static") + with pytest.raises(AttributeError, match=f"A component 'static'.*'{field}'"): + getattr(component, field) + assert not hasattr(component, field) + assert getattr(component, field, "fallback") == "fallback" + + +def test_normal_attributes_and_inherited_ports() -> None: + class Derived(A): + io = IO(outputs=["extra"]) + + component = Derived(name="derived") + component.custom = 42 + assert component.name == "derived" + assert component.custom == 42 + for field in ("a", "b", "c", "extra"): + assert getattr(component, field) is None + + +def test_attribute_lookup_before_initialization() -> None: + component = object.__new__(A) + assert component.a is None + with pytest.raises(AttributeError, match="A component ''.*'missing'"): + component.missing + + +def test_dynamic_reader_ports_are_instance_specific(tmp_path: Path) -> None: + reader = FileReader(name="reader", path=tmp_path / "data.csv", field_names=["temperature"]) + other = FileReader(name="other", path=tmp_path / "data.csv", field_names=["pressure"]) + assert reader.temperature is None + assert hasattr(reader, "temperature") + assert not hasattr(other, "temperature") + assert other.pressure is None + assert not hasattr(reader, "pressure") + with pytest.raises(AttributeError, match="FileReader component 'reader'.*'temprature'"): + reader.temprature + + reader.temperature = 12 + assert reader.temperature == 12 + restored = FileReader(**reader.export()["args"]) + assert restored.temperature is None + assert not hasattr(restored, "pressure") + cloned = copy.deepcopy(reader) + assert cloned.temperature == 12 + assert not hasattr(cloned, "pressure") + + +def test_dynamic_writer_ports(tmp_path: Path) -> None: + writer = FileWriter(name="writer", path=tmp_path / "data.csv", field_names=["temperature"]) + assert writer.temperature is None + assert "temperature" not in vars(writer) + assert not hasattr(writer, "temprature") + writer.temperature = None + assert writer._field_inputs == {"temperature": None} + assert "temperature" in vars(writer) + + @pytest.mark.asyncio @pytest.mark.parametrize( "initial_values", [{"a": [-1], "b": [-2]}, {"a": [-2]}, {"a": [-2, -1]}, {}] From 8730688d3a356fdc79501f39eac199e7e531737a Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Tue, 20 Oct 2026 21:30:30 +0100 Subject: [PATCH 2/3] fix: retain validation for uninitialized components --- plugboard/component/component.py | 7 ++++++- tests/integration/test_process_validation.py | 13 ++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/plugboard/component/component.py b/plugboard/component/component.py index 794bd9a6..84efa184 100644 --- a/plugboard/component/component.py +++ b/plugboard/component/component.py @@ -238,7 +238,12 @@ def __setattr__(self, key: str, value: _t.Any) -> None: @property def id(self) -> str: """Unique ID for `Component`.""" - return self.name + try: + return self.name + except AttributeError as e: + raise ValidationError( + "Component invalid: did you forget to call super().__init__ in the constructor?" + ) from e @property def state(self) -> _t.Optional[StateBackend]: diff --git a/tests/integration/test_process_validation.py b/tests/integration/test_process_validation.py index 8e3e3af0..0e0cdd35 100644 --- a/tests/integration/test_process_validation.py +++ b/tests/integration/test_process_validation.py @@ -70,12 +70,11 @@ def __init__(*args: _t.Any, **kwargs: _t.Any) -> None: async def step(self) -> None: self.y = self.x - process = LocalProcess( - components=[A(name="a", iters=10), NoSuperCall(name="test-no-super")], - connectors=[ - AsyncioConnector(spec=ConnectorSpec(source="a.out_1", target="test-no-super.x")) - ], - ) - with pytest.raises(exceptions.ValidationError, match="forget to call super"): + process = LocalProcess( + components=[A(name="a", iters=10), NoSuperCall(name="test-no-super")], + connectors=[ + AsyncioConnector(spec=ConnectorSpec(source="a.out_1", target="test-no-super.x")) + ], + ) await process.init() From 7544bda9ef542f0ebf631a87542d536e2f12a5fb Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Sat, 24 Oct 2026 22:02:10 +0100 Subject: [PATCH 3/3] docs: simplify introductory component documentation --- docs/usage/key-concepts.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/usage/key-concepts.md b/docs/usage/key-concepts.md index b98b425d..06980fdc 100644 --- a/docs/usage/key-concepts.md +++ b/docs/usage/key-concepts.md @@ -21,16 +21,6 @@ When implementing your own components, you will need to: * Optionally define an `init()` method to do any required preparatory steps before the model in run. * In the case of event based models, define custom [`Event`][plugboard.events.Event] subclasses and corresponding event handler methods decorated with `Event.handler`. -Input and output fields can be accessed as component attributes. A declared public -field that has not been assigned a value returns `None`, and `hasattr` returns -`True` for it. Reading the field does not assign a value or make an input ready. -Fields declared at runtime, such as `FileReader` outputs supplied through -`field_names`, follow the same rules using the instance's current `io` controller. -Ordinary attributes retain normal Python behavior: reading an unknown attribute -raises `AttributeError` with the component and field names, and `hasattr` returns -`False`. Assigning `None` explicitly stores it as the field's value; an unset field -has no entry in the instance dictionary. - ### Connectors Data flows between components via **Connectors**. You will need to use these to tell Plugboard how data should flow between your components. Your overall model might be very simple, for example: