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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
24 changes: 14 additions & 10 deletions cuvis/AcquisitionContext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions cuvis/Async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cuvis/Calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cuvis/Export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cuvis/Measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cuvis/ProcessingContext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cuvis/SessionFile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cuvis/Viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cuvis/Worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
102 changes: 102 additions & 0 deletions tests/test_acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
49 changes: 49 additions & 0 deletions tests/test_handle_lifecycle.py
Original file line number Diff line number Diff line change
@@ -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]) == []