Skip to content
Merged
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
18 changes: 9 additions & 9 deletions .github/workflows/contraqctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ jobs:
python-version: [ 3.11, 3.12, 3.13 ]
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true

Expand Down Expand Up @@ -81,12 +81,12 @@ jobs:
version: ${{ steps.get_version.outputs.version }}

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true

Expand All @@ -108,7 +108,7 @@ jobs:
run: uv build

- name: Upload wheels as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
Expand All @@ -133,12 +133,12 @@ jobs:
needs: prepare-release
steps:
- name: Download wheels artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist/

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true

Expand All @@ -165,12 +165,12 @@ jobs:
if: github.event_name == 'release' && !github.event.release.prerelease
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: main

- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true

Expand Down
25 changes: 20 additions & 5 deletions src/contraqctor/contract/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ def collect_errors(self) -> List[_typing.ErrorOnLoad]:
"""
errors = []
if self.has_error:
# A stream that errored on load has no valid data to traverse into,
# so we report its own error and stop here (iterating would re-raise).
errors.append(cast(_typing.ErrorOnLoad, self._data))
return errors
for stream in self:
if stream is None:
continue
Expand Down Expand Up @@ -418,6 +421,13 @@ def load_all(self, strict: bool = False) -> Self:
```
"""
self.load()
if self.has_error:
if strict:
cast(_typing.ErrorOnLoad, self._data).raise_from_error()
# In non-strict mode the collection failed to load its own children,
# so there is nothing further to recurse into; the error is preserved
# and surfaced via collect_errors().
return self
for stream in self:
if stream is None:
continue
Expand Down Expand Up @@ -539,16 +549,21 @@ def load(self) -> Self:

Overrides the base method to add validation that loaded data is a list of DataStreams.

If the underlying read raised, the error is preserved as an ``ErrorOnLoad`` (as in the
base implementation) rather than re-raised, so that non-strict loading can collect it.
A non-list result from a *successful* read is a genuine contract violation and is captured
as an ``ErrorOnLoad`` as well.

Returns:
Self: The collection instance for method chaining.

Raises:
ValueError: If loaded data is not a list of DataStreams.
"""
super().load()
if self.has_error:
# read() raised and was captured as an ErrorOnLoad; leave it in place.
return self
if not isinstance(self._data, list):
self._data = _typing.UnsetData
raise ValueError("Data must be a list of DataStreams.")
self._data = _typing.ErrorOnLoad(self, exception=ValueError("Data must be a list of DataStreams."))
return self
self._update_data_stream_mapping()
return self

Expand Down
159 changes: 141 additions & 18 deletions src/contraqctor/qc/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,33 @@ class Suite(abc.ABC):
All test suites should inherit from this class and implement test methods
that start with 'test'.

.. important::
**Keep ``__init__`` free of data I/O; load data in :meth:`setup_suite`
instead.** A suite's constructor should only store references to the data
streams it needs; it must not read a stream's ``.data`` (or otherwise
perform I/O that can fail). The :class:`Runner` only wraps the execution of
``test_*`` methods — together with the lifecycle hooks
:meth:`setup_suite`/:meth:`teardown_suite` and :meth:`setup`/:meth:`teardown`
— in exception handling. Any exception raised while *constructing* a suite
(e.g. accessing a register whose ``.bin`` file is missing on an incomplete
dataset) propagates out and aborts the whole suite-assembly step before a
single test runs.

The rule: anything that performs I/O (or any other work that can fail)
belongs in a lifecycle hook, not ``__init__``. The hooks mirror
:mod:`unittest`:

* :meth:`setup_suite` / :meth:`teardown_suite` run **once per suite**
(like ``setUpClass`` / ``tearDownClass``). This is the right place to
load data: it happens a single time, and if it fails every test in the
suite is reported as an error rather than crashing assembly.
* :meth:`setup` / :meth:`teardown` run **once per test** (like ``setUp`` /
``tearDown``).

Note these hooks run at execution time, not at construction or collection.
When invoking a ``test_*`` method directly (e.g. in unit tests) call
:meth:`setup_suite` first to populate the data it relies on.

Examples:
```python
from contraqctor.qc.base import Suite
Expand Down Expand Up @@ -684,22 +711,88 @@ def skip_test(self, message: t.Optional[str] = None, *, context: t.Optional[t.An
description=description,
)

def setup_suite(self) -> None:
"""Run once before any test method in the suite.

Mimics :meth:`unittest.TestCase.setUpClass`. Override this to perform
expensive or failure-prone preparation — e.g. loading a data stream's
``.data`` — a single time for the whole suite, rather than in ``__init__``
(which is not protected by exception handling) or in :meth:`setup` (which
re-runs before every test).

The :class:`Runner` invokes this inside an exception-handling block. If it
raises, every test in the suite is reported as an error instead of being
run, and :meth:`teardown_suite` is *not* called. Suite construction and
test discovery are unaffected.
"""
pass

def teardown_suite(self) -> None:
"""Run once after all test methods in the suite have run.

Mimics :meth:`unittest.TestCase.tearDownClass`. Only invoked if
:meth:`setup_suite` completed successfully.
"""
pass

def setup(self) -> None:
"""Run before each test method.

This method can be overridden by subclasses to implement
setup logic that runs before each test.
Mimics :meth:`unittest.TestCase.setUp`. This method can be overridden by
subclasses to implement setup logic that runs before each test. For work
that should happen only once for the whole suite, override
:meth:`setup_suite` instead.
"""
pass

def teardown(self) -> None:
"""Run after each test method.

This method can be overridden by subclasses to implement
teardown logic that runs after each test.
Mimics :meth:`unittest.TestCase.tearDown`. This method can be overridden
by subclasses to implement teardown logic that runs after each test. For
work that should happen only once for the whole suite, override
:meth:`teardown_suite` instead.
"""
pass

def _try_setup_suite(self) -> t.Optional[t.Tuple[Exception, str]]:
"""Run suite-level setup, capturing any exception.

Returns:
None if :meth:`setup_suite` succeeded, otherwise a tuple of the raised
exception and its formatted traceback.
"""
try:
self.setup_suite()
return None
except Exception as e:
return e, traceback.format_exc()

def _suite_setup_error_result(self, test_method: ITest, exception: Exception, tb: str) -> Result:
"""Build an ERROR result for a test that could not run due to suite-setup failure.

Args:
test_method: The test method that was skipped.
exception: The exception raised by :meth:`setup_suite`.
tb: The formatted traceback for the exception.

Returns:
Result: An ERROR result attributed to the skipped test.
"""
test_name = test_method.__name__
return Result(
status=Status.ERROR,
result=None,
test_name=test_name,
suite_name=self.name,
description=getattr(test_method, "__doc__", None),
message=f"Error during suite setup: {str(exception)}",
exception=exception,
traceback=tb,
test_reference=test_method,
suite_reference=self,
)

def _process_test_result(
self, result: t.Optional[Result], test_method: ITest, test_name: str, description: t.Optional[str]
) -> Result:
Expand Down Expand Up @@ -787,14 +880,26 @@ def run_test(self, test_method: ITest) -> t.Generator[Result, None, None]:
def run_all(self) -> t.Generator[Result, None, None]:
"""Run all test methods in the suite.

Finds all test methods and runs them in sequence.
Runs :meth:`setup_suite` once, then all test methods in sequence, then
:meth:`teardown_suite`. If :meth:`setup_suite` raises, every test is
yielded as an error result and no tests are run.

Yields:
Result: Result objects produced by all test methods.
"""
tests = list(self.get_tests())
setup_failure = self._try_setup_suite()
if setup_failure is not None:
exception, tb = setup_failure
for test in tests:
yield self._suite_setup_error_result(test, exception, tb)
return

for test in self.get_tests():
yield from self.run_test(test)
try:
for test in tests:
yield from self.run_test(test)
finally:
self.teardown_suite()


@dataclasses.dataclass
Expand Down Expand Up @@ -1213,17 +1318,26 @@ def _run_suite_tests(
suite_task = progress.add_task(f"[cyan]{suite_name}".ljust(suite_name_width + 5), total=len(tests))
suite_results = []

for test in tests:
test_name = test.__name__
test_desc = f"[cyan]{suite_name:<{suite_name_width}} • {test_name:<{test_name_width}}"
progress.update(suite_task, description=test_desc)
setup_failure = suite._try_setup_suite()
try:
for test in tests:
test_name = test.__name__
test_desc = f"[cyan]{suite_name:<{suite_name_width}} • {test_name:<{test_name_width}}"
progress.update(suite_task, description=test_desc)

test_results = list(suite.run_test(test))
suite_results.extend(test_results)
if setup_failure is None:
test_results = list(suite.run_test(test))
else:
exception, tb = setup_failure
test_results = [suite._suite_setup_error_result(test, exception, tb)]
suite_results.extend(test_results)

progress.advance(total_task)
progress.advance(group_task)
progress.advance(suite_task)
progress.advance(total_task)
progress.advance(group_task)
progress.advance(suite_task)
finally:
if setup_failure is None:
suite.teardown_suite()

if tests:
self._update_suite_progress(progress, suite_task, suite_name, suite_results, suite_name_width)
Expand Down Expand Up @@ -1271,8 +1385,17 @@ def run_all(self) -> t.Dict[t.Optional[str], t.List[Result]]:
for group, tests_in_group in _TaggedTest.group_by_group(collected_tests):
for suite, tests_in_suite in _TaggedTest.group_by_suite(tests_in_group):
results: t.List[Result] = []
for test in tests_in_suite:
results.extend(suite.run_test(test.test))
setup_failure = suite._try_setup_suite()
try:
for test in tests_in_suite:
if setup_failure is None:
results.extend(suite.run_test(test.test))
else:
exception, tb = setup_failure
results.append(suite._suite_setup_error_result(test.test, exception, tb))
finally:
if setup_failure is None:
suite.teardown_suite()
for result in results:
collected_results.append(
_TaggedResult(suite=suite, group=group, result=result, test=result.test_reference)
Expand Down
16 changes: 14 additions & 2 deletions src/contraqctor/qc/harp/environment_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,20 @@ def __init__(
"""
super().__init__(harp_device)
self.harp_device = harp_device
self.data: pd.DataFrame = self.harp_device["SensorData"].data.copy()
self.data = self.data[self.data["MessageType"] == "EVENT"]
self.data: pd.DataFrame

@override
def setup_suite(self) -> None:
"""Load the environment sensor event data.

Data reads are deferred out of ``__init__`` and into ``setup_suite`` (which
the runner calls once per suite inside its exception-handling block) so that
a missing/corrupt ``SensorData`` register surfaces as a failed test rather
than crashing suite construction. See :class:`contraqctor.qc.base.Suite`.
"""
super().setup_suite()
data = self.harp_device["SensorData"].data.copy()
self.data = data[data["MessageType"] == "EVENT"]

def test_sampling_rate(self):
"""Tests if the sampling rate of the environment sensor is within nominal values"""
Expand Down
16 changes: 14 additions & 2 deletions src/contraqctor/qc/harp/lickety_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,21 @@ def __init__(
"""
super().__init__(harp_device)
self.harp_device = harp_device
self.data: pd.DataFrame = self.harp_device["LickState"].data.copy()
self.data = self.data[self.data["MessageType"] == "EVENT"]
self.lick_refractory_period = lick_refractory_period
self.data: pd.DataFrame

@override
def setup_suite(self) -> None:
"""Load the lick-state event data.

Data reads are deferred out of ``__init__`` and into ``setup_suite`` (which
the runner calls once per suite inside its exception-handling block) so that
a missing/corrupt ``LickState`` register surfaces as a failed test rather
than crashing suite construction. See :class:`contraqctor.qc.base.Suite`.
"""
super().setup_suite()
data = self.harp_device["LickState"].data.copy()
self.data = data[data["MessageType"] == "EVENT"]

@staticmethod
def _get_distinct_from_channel(data: pd.DataFrame, channel: str):
Expand Down
Loading
Loading