diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad2d53..f440f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Pre-releases (`b*`, `rc*`) are not listed. - `pyproject.toml` - `requires-python` raised from `>=3.9` to `>=3.10`, Python 3.9 having reached end of life in October 2025. - Annotations throughout `cuvis` restated in the forms Python 3.10 provides: `Union[A, B]` and `Optional[A]` became `A | B` and `A | None`, and `Tuple`, `FrozenSet`, `Sequence`, `Callable` and `Awaitable` now come from `builtins` and `collections.abc` rather than `typing`. Every signature denotes what it denoted before; `cuvis.cube_utils.ImageData.__getitem__` keeps `Union`, because its member list contains a forward reference and `|` cannot join a type to a string at runtime. +- `cuvis.AcquisitionContext.capture` - parameter `to_interal` renamed to `to_internal`. ### Removed @@ -66,6 +67,8 @@ Pre-releases (`b*`, `rc*`) are not listed. - `tests/test_general.py` - `test_wrapper_version` asserted the literal `3.5.3`, so it had to be edited on every SDK bump and passed only because an editable install exposed the untracked root `git-hash.txt`. It now compares against the installed distribution version. - `cuvis.AcquisitionContext.register_ready_callback` - parameter `callback` was annotated `Callable[None, Awaitable[None]]`, which is not a valid `Callable` form; it is now `Callable[[], Awaitable[None]]`, matching the no-argument call the implementation makes. +- `cuvis.AcquisitionContext.capture` - `to_internal=True` raised `TypeError` instead of queueing the measurement, because it passed a Python `0` where SWIG requires a null pointer. +- `cuvis.Calibration`, `cuvis.AcquisitionContext`, `cuvis.ProcessingContext`, `cuvis.SessionFile`, `cuvis.Measurement`, `cuvis.Viewer`, `cuvis.Worker`, `cuvis.CubeExporter`, `cuvis.EnviExporter`, `cuvis.TiffExporter`, `cuvis.ViewExporter` - `__del__` raised `TypeError` after a failed construction, because it freed a handle that was still `None`. ## [3.5.3.2] - 2026-08-19 diff --git a/cuvis/AcquisitionContext.py b/cuvis/AcquisitionContext.py index 0b05f89..2bc3d6f 100644 --- a/cuvis/AcquisitionContext.py +++ b/cuvis/AcquisitionContext.py @@ -249,20 +249,22 @@ def _set_integration_time_factor_async(self, idref: int, val: float) -> Async: return Async(cuvis_il.p_int_value(_pasync)) @copydoc(cuvis_il.cuvis_acq_cont_capture_async) - def capture(self, to_interal=False) -> AsyncMesu | None: - if not to_interal: - _pasync = cuvis_il.new_p_int() + def capture(self, to_internal: bool = False) -> AsyncMesu | None: + if to_internal: + # A null result handle is what routes the measurement to the acquisition + # context's internal queue, to be picked up with get_next_measurement. + # SWIG marshals None to NULL; a Python 0 is rejected as not a pointer. if cuvis_il.status_ok != cuvis_il.cuvis_acq_cont_capture_async( - self._handle, _pasync - ): - raise SDKException() - return AsyncMesu(cuvis_il.p_int_value(_pasync)) - else: - if cuvis_il.status_ok != cuvis_il.cuvis_acq_cont_capture_async( - self._handle, 0 + self._handle, None ): raise SDKException() return None + _pasync = cuvis_il.new_p_int() + if cuvis_il.status_ok != cuvis_il.cuvis_acq_cont_capture_async( + self._handle, _pasync + ): + raise SDKException() + return AsyncMesu(cuvis_il.p_int_value(_pasync)) @copydoc(cuvis_il.cuvis_acq_cont_capture) def capture_at(self, timeout_ms: int) -> Measurement: @@ -608,6 +610,8 @@ def components(self): pass def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_acq_cont_free(_ptr) diff --git a/cuvis/Async.py b/cuvis/Async.py index 42d4920..e9ad752 100644 --- a/cuvis/Async.py +++ b/cuvis/Async.py @@ -61,6 +61,8 @@ async def _wait_for_return(): return _wait_for_return().__await__() def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_async_capture_free(_ptr) @@ -115,6 +117,8 @@ async def _wait_for_return(): return _wait_for_return().__await__() def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_async_call_free(_ptr) diff --git a/cuvis/Calibration.py b/cuvis/Calibration.py index 1a08bfc..542b0d4 100644 --- a/cuvis/Calibration.py +++ b/cuvis/Calibration.py @@ -48,6 +48,8 @@ def id(self) -> str: return _id def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_calib_free(_ptr) diff --git a/cuvis/Export.py b/cuvis/Export.py index 62902bf..a54a4c9 100644 --- a/cuvis/Export.py +++ b/cuvis/Export.py @@ -16,6 +16,8 @@ def __init__(self): pass def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_exporter_free(_ptr) diff --git a/cuvis/Measurement.py b/cuvis/Measurement.py index 292c888..c08728e 100644 --- a/cuvis/Measurement.py +++ b/cuvis/Measurement.py @@ -327,6 +327,8 @@ def deepcopy(self): return copy def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() self.clear_cube() cuvis_il.p_int_assign(_ptr, self._handle) diff --git a/cuvis/ProcessingContext.py b/cuvis/ProcessingContext.py index 68ccb3c..568596f 100644 --- a/cuvis/ProcessingContext.py +++ b/cuvis/ProcessingContext.py @@ -139,6 +139,8 @@ def calibration_id(self) -> str: return _id def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_proc_cont_free(_ptr) diff --git a/cuvis/SessionFile.py b/cuvis/SessionFile.py index 3c3e901..85db182 100644 --- a/cuvis/SessionFile.py +++ b/cuvis/SessionFile.py @@ -108,6 +108,8 @@ def __getitem__(self, key: int) -> Measurement: return self.get_measurement(key) def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_session_file_free(_ptr) diff --git a/cuvis/Viewer.py b/cuvis/Viewer.py index e022166..ac2bae2 100644 --- a/cuvis/Viewer.py +++ b/cuvis/Viewer.py @@ -61,6 +61,8 @@ def apply(self, mesu: Measurement) -> dict[str, ImageData] | ImageData: return self._create_view_data(currentView) def __del__(self): + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_viewer_free(_ptr) diff --git a/cuvis/Worker.py b/cuvis/Worker.py index 6039eff..12e982e 100644 --- a/cuvis/Worker.py +++ b/cuvis/Worker.py @@ -332,6 +332,8 @@ def reset_worker_callback(self) -> None: def __del__(self): self.reset_worker_callback() + if self._handle is None: + return _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) cuvis_il.cuvis_worker_free(_ptr) diff --git a/tests/test_acquisition.py b/tests/test_acquisition.py index 8831548..894a966 100644 --- a/tests/test_acquisition.py +++ b/tests/test_acquisition.py @@ -8,6 +8,8 @@ import pytest import time import cuvis +from cuvis.cuvis_aux import SDKException +from cuvis.cuvis_types import AsyncResult def test_simulated_acquisition_context_creation(simulated_acquisition_context): @@ -99,3 +101,103 @@ def test_acquisition_context_component_count(simulated_acquisition_context): count = simulated_acquisition_context.component_count assert isinstance(count, int) assert count >= 0 + + +def _drain(acq): + """Take the queued measurement back out, so the next test sees an empty queue.""" + deadline = time.time() + 10 + while time.time() < deadline: + try: + return acq.get_next_measurement(500) + except SDKException: + continue + pytest.fail("the capture never reached the internal queue") + + +def _ready_software_context(acq): + """A simulated context in Software mode, ready to be triggered.""" + acq.operation_mode = cuvis.OperationMode.Software + deadline = time.time() + 10 + while not acq.ready and time.time() < deadline: + time.sleep(0.1) + if not acq.ready: + pytest.skip("Acquisition context not ready within timeout") + return acq + + +def _assert_queue_empty(acq): + with pytest.raises(SDKException): + acq.get_next_measurement(300) + + +def test_capture_to_internal_queues_the_measurement( + simulated_acquisition_context, processing_context_from_session +): + """to_internal=True hands the SDK a null result handle, which queues the capture. + + The queue is checked empty on both sides so the measurement cannot be anything but + this capture, and the result is processed to prove it is a usable measurement rather + than a handle the SDK never filled in. + """ + acq = _ready_software_context(simulated_acquisition_context) + _assert_queue_empty(acq) + + assert acq.capture(to_internal=True) is None + + mesu = _drain(acq) + assert isinstance(mesu, cuvis.Measurement) + _assert_queue_empty(acq) + + processing_context_from_session.processing_mode = cuvis.ProcessingMode.Raw + processing_context_from_session.apply(mesu) + assert mesu.cube.array.size > 0 + + +def test_worker_receives_a_capture_from_the_internal_queue( + simulated_acquisition_context, processing_context_from_session +): + """The other consumer cuvis.h:1887 names for the internal queue.""" + acq = _ready_software_context(simulated_acquisition_context) + _assert_queue_empty(acq) + + worker = cuvis.Worker(cuvis.WorkerSettings(output_queue_size=8)) + worker.set_acquisition_context(acq) + processing_context_from_session.processing_mode = cuvis.ProcessingMode.Raw + worker.set_processing_context(processing_context_from_session) + worker.start_processing() + try: + assert acq.capture(to_internal=True) is None + + deadline = time.time() + 15 + while time.time() < deadline: + try: + result = worker.get_next_result(1000) + except SDKException: + continue + assert isinstance(result.mesu, cuvis.Measurement) + return + pytest.fail("the worker never saw the queued capture") + finally: + worker.stop_processing() + worker.drop_all_queued() + worker.set_acquisition_context(None) + + +def test_capture_returns_an_async_measurement_without_queueing_it( + simulated_acquisition_context, +): + """The other half of the same switch: a result handle means no queue entry. + + The async result is collected first, so the capture is known to have happened and an + empty queue afterwards cannot be mistaken for a capture that never ran. Three windows + rather than one, so a late delivery does not pass as an absence. + """ + acq = _ready_software_context(simulated_acquisition_context) + _assert_queue_empty(acq) + + mesu, result = acq.capture().get(5000) + assert isinstance(mesu, cuvis.Measurement) + assert result is AsyncResult.done + + for _ in range(3): + _assert_queue_empty(acq) diff --git a/tests/test_handle_lifecycle.py b/tests/test_handle_lifecycle.py new file mode 100644 index 0000000..23b4c8d --- /dev/null +++ b/tests/test_handle_lifecycle.py @@ -0,0 +1,49 @@ +"""Every SDK handle owner must survive a failed construction. + +These classes set ``self._handle = None`` before the call that would fill it in, so a +constructor that raises leaves a half-built object for the garbage collector. ``__del__`` +then hands that ``None`` to the SWIG free function, which raises. Python does not +propagate an exception out of ``__del__``; it prints it, so the symptom is an unrelated +looking traceback appearing long after the real error the caller already handled. +""" + +import gc +import sys + +import pytest + +import cuvis + + +def _construct(make): + """Run a failing constructor and collect, recording anything ``__del__`` raises.""" + unraisable = [] + previous = sys.unraisablehook + sys.unraisablehook = unraisable.append + try: + with pytest.raises(Exception): + make() + gc.collect() + finally: + sys.unraisablehook = previous + return [type(entry.exc_value).__name__ for entry in unraisable] + + +# Every handle owner whose constructor can fail after _handle is set to None. AsyncMesu +# and AsyncResult are absent on purpose: they take an already valid handle and have no +# failing path, so their guard is defensive only. +FAILING_CONSTRUCTORS = { + "Calibration": lambda: cuvis.Calibration("no_such_calibration"), + "Measurement": lambda: cuvis.Measurement("no_such_measurement.cu3"), + "SessionFile": lambda: cuvis.SessionFile("no_such_session.cu3s"), + "ProcessingContext": lambda: cuvis.ProcessingContext("not a base"), + "AcquisitionContext": lambda: cuvis.AcquisitionContext("not a base"), + "CubeExporter": lambda: cuvis.CubeExporter("not export settings"), + "Worker": lambda: cuvis.Worker("not worker settings"), + "Viewer": lambda: cuvis.Viewer("not viewer settings"), +} + + +@pytest.mark.parametrize("name", sorted(FAILING_CONSTRUCTORS)) +def test_failed_construction_is_collected_quietly(name, sdk_initialized): + assert _construct(FAILING_CONSTRUCTORS[name]) == []