diff --git a/src/contraqctor/contract/base.py b/src/contraqctor/contract/base.py index 2ae8e74..93d8e09 100644 --- a/src/contraqctor/contract/base.py +++ b/src/contraqctor/contract/base.py @@ -605,10 +605,17 @@ def __str__(self: Self) -> str: def __iter__(self) -> Generator[DataStream, None, None]: """Iterator for child data streams. - Yields: - DataStream: Child data streams. + A collection that failed to load has no valid children to traverse, so it + yields nothing rather than re-raising its stored ``ErrorOnLoad``. This keeps + traversal consistent with ``load_all(strict=False)``: a partially-loaded + hierarchy stays iterable, and load errors remain retrievable via + :meth:`collect_errors`. Accessing a stream's ``.data`` directly still raises. + Yields: + DataStream: Child data streams (none if this collection failed to load). """ + if self.has_error: + return # We intentionally yield from self.data to trigger # automatic loading if needed yield from self.data @@ -616,7 +623,9 @@ def __iter__(self) -> Generator[DataStream, None, None]: def iter_all(self) -> Generator[DataStream, None, None]: """Iterator for all child data streams, including nested collections. - Implements a depth-first traversal of the stream hierarchy. + Implements a depth-first traversal of the stream hierarchy. An errored + sub-collection is yielded as a node but not descended into (see + :meth:`__iter__`), so traversal never aborts on a partially-loaded tree. Yields: DataStream: All recursively yielded child data streams. diff --git a/tests/test_contract/test_core.py b/tests/test_contract/test_core.py index 7107499..c9a101a 100644 --- a/tests/test_contract/test_core.py +++ b/tests/test_contract/test_core.py @@ -502,3 +502,46 @@ def _reader(params: SimpleParams) -> list: assert collection.has_error with pytest.raises(ValueError, match="Data must be a list of DataStreams."): collection.collect_errors()[0].raise_from_error() + + def test_iter_failed_collection_yields_nothing(self, temp_dir): + """Iterating a collection that failed to load yields nothing instead of re-raising.""" + nonexistent_path = temp_dir / "nonexistent_folder" / "device.yml" + failing_collection = _FailingCollection(name="failing", reader_params=SimpleParams(path=nonexistent_path)) + + failing_collection.load() + assert failing_collection.has_error + + # Iteration / traversal must not re-raise the stored ErrorOnLoad. + assert list(failing_collection) == [] + assert list(failing_collection.iter_all()) == [] + + # Accessing the data directly still raises. + with pytest.raises(FileNotFoundError): + _ = failing_collection.data + + def test_iter_all_survives_failed_subcollection(self, text_file, temp_dir): + """iter_all traverses a partial hierarchy without aborting on an errored sub-collection.""" + working = SimpleDataStream(name="working", reader_params=SimpleParams(path=text_file)) + + nonexistent_path = temp_dir / "nonexistent_folder" / "device.yml" + failing_collection = _FailingCollection(name="failing", reader_params=SimpleParams(path=nonexistent_path)) + + root = DataStreamCollection(name="root", data_streams=[working, failing_collection]) + root.load_all(strict=False) + assert failing_collection.has_error + + # Traversal must not raise; it yields the streams that loaded plus the errored node, + # but nothing from inside the errored sub-collection. + all_streams = list(root.iter_all()) + assert working in all_streams + assert failing_collection in all_streams + + # Direct iteration of the parent also works. + children = list(root) + assert working in children + assert failing_collection in children + + # Errors are still surfaced through the normal error-collection path. + errors = root.collect_errors() + assert len(errors) == 1 + assert errors[0].data_stream == failing_collection