Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions plugboard/component/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<unnamed>")
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.
Expand All @@ -229,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]:
Expand Down
13 changes: 6 additions & 7 deletions tests/integration/test_process_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
78 changes: 78 additions & 0 deletions tests/unit/test_component.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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 '<unnamed>'.*'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]}, {}]
Expand Down
Loading