From a562431409ce7bc3fc65435ec3cf57c07209f603 Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Wed, 19 Aug 2026 11:53:46 +0200 Subject: [PATCH 1/2] format files --- cuvis/AcquisitionContext.py | 6 +- cuvis/Async.py | 43 +++++----- cuvis/Calibration.py | 21 +++-- cuvis/Export.py | 32 ++++---- cuvis/FileWriteSettings.py | 14 ++-- cuvis/General.py | 10 ++- cuvis/Measurement.py | 121 ++++++++++++++++----------- cuvis/SessionFile.py | 57 +++++++------ cuvis/Viewer.py | 36 ++++---- cuvis/Worker.py | 109 ++++++++++++++---------- cuvis/_cuvis_il.py | 6 +- cuvis/cube_utils.py | 77 ++++++++++++----- cuvis/cuvis_aux.py | 107 ++++++++++++------------ cuvis/cuvis_types.py | 8 +- cuvis/doc.py | 14 ++-- git-hash.txt | 1 - prebuild.py | 4 +- pyproject.toml | 16 ++++ scripts/check_changelog.py | 137 +++++++++++++++++++++++++++++++ scripts/release_meta.py | 24 ++++++ tests/test_acquisition.py | 6 +- tests/test_cube_utils.py | 28 +++++-- tests/test_general.py | 5 +- tests/test_measurement.py | 8 +- tests/test_processing_context.py | 1 - tests/test_session_file.py | 1 - 26 files changed, 588 insertions(+), 304 deletions(-) delete mode 100644 git-hash.txt create mode 100644 scripts/check_changelog.py create mode 100644 scripts/release_meta.py diff --git a/cuvis/AcquisitionContext.py b/cuvis/AcquisitionContext.py index 2983702..ab136f6 100644 --- a/cuvis/AcquisitionContext.py +++ b/cuvis/AcquisitionContext.py @@ -6,7 +6,7 @@ from .cuvis_aux import SDKException, SessionData, ComponentInfo from .cuvis_types import HardwareState, OperationMode -from typing import Coroutine, Callable, Awaitable, Union, Iterable, Optional +from typing import Callable, Awaitable, Union, Optional from .doc import copydoc import cuvis.cuvis_types as internal @@ -563,7 +563,6 @@ def register_state_change_callback( self, callback: Callable[[HardwareState, list[tuple[str, bool]]], Awaitable[None]], ) -> None: - """ """ self.reset_state_change_callback() async def _internal_state_loop(): @@ -600,7 +599,6 @@ async def _internal_state_loop(): self._state_poll_task = a.create_task(_internal_state_loop()) def reset_state_change_callback(self) -> None: - """ """ if self._state_poll_task is not None: self._state_poll_task.cancel() self._state_poll_task = None @@ -629,8 +627,6 @@ def __copy__(self): class Component: - """ """ - def __init__(self, acq: AcquisitionContext, idx: int): self._acq = acq self._idx = idx diff --git a/cuvis/Async.py b/cuvis/Async.py index 0339a97..bc8a1b3 100644 --- a/cuvis/Async.py +++ b/cuvis/Async.py @@ -15,7 +15,7 @@ def _to_ms(value: Union[int, timedelta]) -> int: elif isinstance(value, int): return value else: - raise SDKException('Unknown type for converting to ms') + raise SDKException("Unknown type for converting to ms") class AsyncMesu(object): @@ -24,15 +24,13 @@ def __init__(self, handle): pass - def get(self, timeout_ms: Union[int, timedelta]) -> tuple[Optional[Measurement], AsyncResult]: - """ - - """ + def get( + self, timeout_ms: Union[int, timedelta] + ) -> tuple[Optional[Measurement], AsyncResult]: _ptr = cuvis_il.new_p_int() _pmesu = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) - res = cuvis_il.cuvis_async_capture_get( - _ptr, _to_ms(timeout_ms), _pmesu) + res = cuvis_il.cuvis_async_capture_get(_ptr, _to_ms(timeout_ms), _pmesu) if res == cuvis_il.status_ok: return Measurement(cuvis_il.p_int_value(_pmesu)), AsyncResult.done @@ -51,13 +49,16 @@ def __await__(self) -> Optional[Measurement]: async def _wait_for_return(): _status_ptr = cuvis_il.new_p_cuvis_status_t() while True: - if cuvis_il.status_ok != cuvis_il.cuvis_async_capture_status(self._handle, _status_ptr): + if cuvis_il.status_ok != cuvis_il.cuvis_async_capture_status( + self._handle, _status_ptr + ): raise SDKException() status = cuvis_il.p_cuvis_status_t_value(_status_ptr) if status == cuvis_il.status_ok: return self.get(0)[0] else: await a.sleep(10.0 / 1000) + return _wait_for_return().__await__() def __del__(self): @@ -67,13 +68,12 @@ def __del__(self): self._handle = cuvis_il.p_int_value(_ptr) def __deepcopy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Deep copying is not supported for AsyncMesu') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Deep copying is not supported for AsyncMesu") def __copy__(self): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError( - 'Shallow copying is not supported for AsyncMesu') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for AsyncMesu") class Async(object): @@ -81,9 +81,6 @@ def __init__(self, handle): self._handle = handle def get(self, timeout_ms: Union[int, timedelta]) -> AsyncResult: - """ - - """ _ptr = cuvis_il.new_p_int() cuvis_il.p_int_assign(_ptr, self._handle) res = cuvis_il.cuvis_async_call_get(_ptr, _to_ms(timeout_ms)) @@ -106,13 +103,16 @@ def __await__(self) -> AsyncResult: async def _wait_for_return(): _status_ptr = cuvis_il.new_p_cuvis_status_t() while True: - if cuvis_il.status_ok != cuvis_il.cuvis_async_call_status(self._handle, _status_ptr): + if cuvis_il.status_ok != cuvis_il.cuvis_async_call_status( + self._handle, _status_ptr + ): raise SDKException() status = cuvis_il.p_cuvis_status_t_value(_status_ptr) if status == cuvis_il.status_ok: return self.get(0) else: await a.sleep(10.0 / 1000) + return _wait_for_return().__await__() def __del__(self): @@ -122,10 +122,9 @@ def __del__(self): self._handle = cuvis_il.p_int_value(_ptr) def __deepcopy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Deep copying is not supported for Async') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Deep copying is not supported for Async") def __copy__(self): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError( - 'Shallow copying is not supported for Async') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for Async") diff --git a/cuvis/Calibration.py b/cuvis/Calibration.py index 67234c8..b84f694 100644 --- a/cuvis/Calibration.py +++ b/cuvis/Calibration.py @@ -12,18 +12,17 @@ class Calibration(object): - def __init__(self, base: Union[Path, str, SessionFile]): self._handle = None _ptr = cuvis_il.new_p_int() if isinstance(base, SessionFile): - retval = cuvis_il.cuvis_calib_create_from_session_file( - base._handle, _ptr) + retval = cuvis_il.cuvis_calib_create_from_session_file(base._handle, _ptr) elif (isinstance(base, Path) and base.is_dir()) or os.path.exists(base): retval = cuvis_il.cuvis_calib_create_from_path(str(base), _ptr) else: raise SDKException( - "Could not interpret input of type '{}'.".format(type(base))) + "Could not interpret input of type '{}'.".format(type(base)) + ) if cuvis_il.status_ok != retval: raise SDKException() self._handle = cuvis_il.p_int_value(_ptr) @@ -33,15 +32,15 @@ def get_capabilities(self, operation_mode: OperationMode) -> Capabilities: _ptr = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_calib_get_capabilities( - self._handle, internal.__CuvisOperationMode__[operation_mode], _ptr): + self._handle, internal.__CuvisOperationMode__[operation_mode], _ptr + ): raise SDKException() return Capabilities(cuvis_il.p_int_value(_ptr)) @property def info(self) -> CalibrationInfo: ret = cuvis_il.cuvis_calibration_info_t() - if cuvis_il.status_ok != cuvis_il.cuvis_calib_get_info( - self._handle, ret): + if cuvis_il.status_ok != cuvis_il.cuvis_calib_get_info(self._handle, ret): raise SDKException() return CalibrationInfo._from_internal(ret) @@ -56,9 +55,9 @@ def __del__(self): cuvis_il.cuvis_calib_free(_ptr) def __deepcopy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Deep copying is not supported for Calibration') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Deep copying is not supported for Calibration") def __copy__(self): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Shallow copying is not supported for Calibration') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for Calibration") diff --git a/cuvis/Export.py b/cuvis/Export.py index 015deb5..62902bf 100644 --- a/cuvis/Export.py +++ b/cuvis/Export.py @@ -2,7 +2,12 @@ from .cuvis_aux import SDKException from .Measurement import Measurement -from .FileWriteSettings import GeneralExportSettings, EnviExportSettings, TiffExportSettings, ViewExportSettings, SaveArgs +from .FileWriteSettings import ( + EnviExportSettings, + TiffExportSettings, + ViewExportSettings, + SaveArgs, +) class Exporter(object): @@ -17,8 +22,9 @@ def __del__(self): pass def apply(self, mesu: Measurement) -> Measurement: - if cuvis_il.status_ok != cuvis_il.cuvis_exporter_apply(self._handle, - mesu._handle): + if cuvis_il.status_ok != cuvis_il.cuvis_exporter_apply( + self._handle, mesu._handle + ): raise SDKException() mesu.refresh() return mesu @@ -28,18 +34,19 @@ def flush(self): raise SDKException() def __deepcopy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Deep copying is not supported for Exporter') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Deep copying is not supported for Exporter") def __copy__(self): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Shallow copying is not supported for Exporter') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for Exporter") @property def queue_used(self) -> int: _ptr = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_exporter_get_queue_used( - self._handle, _ptr): + self._handle, _ptr + ): raise SDKException() return cuvis_il.p_int_value(_ptr) @@ -49,8 +56,7 @@ def __init__(self, fs: SaveArgs): super().__init__() _ptr = cuvis_il.new_p_int() ge, fs = fs._get_internal() - if cuvis_il.status_ok != cuvis_il.cuvis_exporter_create_cube(_ptr, ge, - fs): + if cuvis_il.status_ok != cuvis_il.cuvis_exporter_create_cube(_ptr, ge, fs): raise SDKException() self._handle = cuvis_il.p_int_value(_ptr) pass @@ -61,8 +67,7 @@ def __init__(self, fs: TiffExportSettings): super().__init__() _ptr = cuvis_il.new_p_int() ge, fs = fs._get_internal() - if cuvis_il.status_ok != cuvis_il.cuvis_exporter_create_tiff(_ptr, ge, - fs): + if cuvis_il.status_ok != cuvis_il.cuvis_exporter_create_tiff(_ptr, ge, fs): raise SDKException() self._handle = cuvis_il.p_int_value(_ptr) pass @@ -84,8 +89,7 @@ def __init__(self, fs: ViewExportSettings): super().__init__() _ptr = cuvis_il.new_p_int() ge, fs = fs._get_internal() - if cuvis_il.status_ok != cuvis_il.cuvis_exporter_create_view(_ptr, ge, - fs): + if cuvis_il.status_ok != cuvis_il.cuvis_exporter_create_view(_ptr, ge, fs): raise SDKException() self._handle = cuvis_il.p_int_value(_ptr) pass diff --git a/cuvis/FileWriteSettings.py b/cuvis/FileWriteSettings.py index e0cd5ce..bcd2bbd 100644 --- a/cuvis/FileWriteSettings.py +++ b/cuvis/FileWriteSettings.py @@ -293,10 +293,11 @@ def userplugin(self, v: str) -> None: self._set_userplugin(userplugin=v) def __repr__(self): - def short_str(s: str, l: int) -> str: - return (s[:l] + "...") if len(s) > l else s - """Returns a string containing but shortens the userplugin field.""" + + def short_str(s: str, limit: int) -> str: + return (s[:limit] + "...") if len(s) > limit else s + s = ", ".join( list(f"{field.name}={getattr(self, field.name)}" for field in fields(self)) + [f"userplugin={short_str(self._userplugin, 15)}"] @@ -554,10 +555,11 @@ def add_pan(self, value: bool) -> None: self.pan_sharpening.add_pan = value def __repr__(self): - def short_str(s: str, l: int) -> str: - return (s[:l] + "...") if len(s) > l else s - """Returns a string containing but shortens the userplugin field.""" + + def short_str(s: str, limit: int) -> str: + return (s[:limit] + "...") if len(s) > limit else s + s = ", ".join( list(f"{field.name}={getattr(self, field.name)}" for field in fields(self)) + [f"userplugin={short_str(self._userplugin, 15)}"] diff --git a/cuvis/General.py b/cuvis/General.py index 384b3bb..b499a4d 100644 --- a/cuvis/General.py +++ b/cuvis/General.py @@ -1,6 +1,5 @@ import logging import os -import platform from importlib.metadata import version as imp_version from ._cuvis_il import cuvis_il @@ -45,9 +44,12 @@ def sdk_version() -> str: def wrapper_version() -> str: pip_version = imp_version("cuvis") - with open(Path(__file__).parent.parent / "git-hash.txt", "r") as f: - git_hash = f.readline() - return f"{pip_version} {git_hash}".strip() + # Written into the package by prebuild.py; absent when the wrapper was + # installed from a tree that never ran it. + git_hash = Path(__file__).parent / "git-hash.txt" + if not git_hash.is_file(): + return pip_version + return f"{pip_version} {git_hash.read_text().splitlines()[0]}".strip() def set_log_level(lvl: Union[int, str]): diff --git a/cuvis/Measurement.py b/cuvis/Measurement.py index 9da8a7a..0aebefe 100644 --- a/cuvis/Measurement.py +++ b/cuvis/Measurement.py @@ -1,17 +1,23 @@ from typing import Union from .FileWriteSettings import SaveArgs import datetime -import os -import numpy as np from pathlib import Path from ._cuvis_il import cuvis_il -from .cuvis_aux import SDKException, SessionData, Capabilities, MeasurementFlags, SensorInfo, GPSData +from .cuvis_aux import ( + SDKException, + SessionData, + Capabilities, + MeasurementFlags, + SensorInfo, + GPSData, +) from .cuvis_types import DataFormat, ProcessingMode, ReferenceType from .cube_utils import ImageData import cuvis.cuvis_types as internal + base_datetime = datetime.datetime(1970, 1, 1) @@ -42,33 +48,35 @@ def __init__(self, base: Union[int, str, Path]): base = Path(base) if not base.exists(): raise FileNotFoundError( - 'Could not open Measurement. File does not exists.') + "Could not open Measurement. File does not exists." + ) _ptr = cuvis_il.new_p_int() - if cuvis_il.status_ok != cuvis_il.cuvis_measurement_load(str(base), - _ptr): + if cuvis_il.status_ok != cuvis_il.cuvis_measurement_load(str(base), _ptr): raise SDKException() self._handle = cuvis_il.p_int_value(_ptr) else: - raise ValueError( - "Could not open Measurement! Unknown Input") + raise ValueError("Could not open Measurement! Unknown Input") self.refresh() pass def _refresh_metadata(self): _metaData = cuvis_il.cuvis_mesu_metadata_allocate() if cuvis_il.status_ok != cuvis_il.cuvis_measurement_get_metadata( - self._handle, _metaData): + self._handle, _metaData + ): raise SDKException self._capture_time = base_datetime + datetime.timedelta( - milliseconds=_metaData.capture_time) + milliseconds=_metaData.capture_time + ) self._measurement_flags = MeasurementFlags(_metaData.measurement_flags) self._path = _metaData.path self._comment = _metaData.comment try: self._factory_calibration = base_datetime + datetime.timedelta( - milliseconds=_metaData.factory_calibration) + milliseconds=_metaData.factory_calibration + ) except OverflowError: self._factory_calibration = None self._assembly = _metaData.assembly @@ -77,12 +85,13 @@ def _refresh_metadata(self): self._integration_time = _metaData.integration_time self._serial_number = _metaData.serial_number self._product_name = _metaData.product_name - self._processing_mode = internal.__ProcessingMode__[ - _metaData.processing_mode] + self._processing_mode = internal.__ProcessingMode__[_metaData.processing_mode] self._name = _metaData.name - self._session_info = SessionData(_metaData.session_info_name, - _metaData.session_info_session_no, - _metaData.session_info_sequence_no) + self._session_info = SessionData( + _metaData.session_info_name, + _metaData.session_info_session_no, + _metaData.session_info_sequence_no, + ) self._frame_id = _metaData.measurement_frame_id cuvis_il.cuvis_mesu_metadata_free(_metaData) @@ -91,50 +100,57 @@ def refresh(self) -> None: self._refresh_metadata() pcount = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_measurement_get_data_count( - self._handle, pcount): + self._handle, pcount + ): raise SDKException() for ind in range(cuvis_il.p_int_value(pcount)): pType = cuvis_il.new_p_cuvis_data_type_t() - key = cuvis_il.cuvis_measurement_get_data_info_swig(self._handle, - pType, ind) + key = cuvis_il.cuvis_measurement_get_data_info_swig( + self._handle, pType, ind + ) cdtype = cuvis_il.p_cuvis_data_type_t_value(pType) if cdtype == cuvis_il.data_type_image: data = cuvis_il.cuvis_imbuffer_t() - cuvis_il.cuvis_measurement_get_data_image(self._handle, - key, - data) + cuvis_il.cuvis_measurement_get_data_image(self._handle, key, data) # t0 = datetime.datetime.now() - self.data.update({key: ImageData(img_buf=data, - dformat=DataFormat[ - data.__getattribute__( - "format")])}) + self.data.update( + { + key: ImageData( + img_buf=data, + dformat=DataFormat[data.__getattribute__("format")], + ) + } + ) # print("image loading time: {}".format( # datetime.datetime.now() - t0)) elif cdtype == cuvis_il.data_type_string: - val = cuvis_il.cuvis_measurement_get_data_string_swig( - self._handle, key) + val = cuvis_il.cuvis_measurement_get_data_string_swig(self._handle, key) self.data.update({key: val}) elif cdtype == cuvis_il.data_type_gps: gps = cuvis_il.cuvis_gps_t() - cuvis_il.cuvis_measurement_get_data_gps(self._handle, key, - gps) + cuvis_il.cuvis_measurement_get_data_gps(self._handle, key, gps) self.data.update({key: GPSData._from_internal(gps)}) elif cdtype == cuvis_il.data_type_sensor_info: info = cuvis_il.cuvis_sensor_info_t() - cuvis_il.cuvis_measurement_get_data_sensor_info(self._handle, - key, info) + cuvis_il.cuvis_measurement_get_data_sensor_info(self._handle, key, info) self.data.update({key: SensorInfo._from_internal(info)}) else: # The C API reports entries it cannot hand out with an empty key, # so they need a distinct one here or they overwrite each other. self.data.update( - {key or "unsupported_data_{}_{}".format(cdtype, ind): - "Not Implemented!"}) + { + key + or "unsupported_data_{}_{}".format( + cdtype, ind + ): "Not Implemented!" + } + ) def save(self, saveargs: SaveArgs) -> None: ge, sa = saveargs._get_internal() if cuvis_il.status_ok != cuvis_il.cuvis_measurement_save( - self._handle, ge.export_dir, sa): + self._handle, ge.export_dir, sa + ): raise SDKException() pass @@ -157,7 +173,8 @@ def comment(self) -> str: @comment.setter def comment(self, comment: str) -> None: if cuvis_il.status_ok != cuvis_il.cuvis_measurement_set_comment( - self._handle, comment): + self._handle, comment + ): raise SDKException() self._refresh_metadata() pass @@ -201,7 +218,8 @@ def name(self) -> str: @name.setter def name(self, name: str) -> None: if cuvis_il.status_ok != cuvis_il.cuvis_measurement_set_name( - self._handle, name): + self._handle, name + ): raise SDKException() self._refresh_metadata() pass @@ -238,17 +256,19 @@ def cube(self) -> ImageData: ImageData The 'cube' data, either retrieved from `self.data` or generated through processing. """ - if 'cube' in self.data: - return self.data.get('cube') + if "cube" in self.data: + return self.data.get("cube") if self._session is not None: # try fallback if session is known if self._session._pc is None: from .ProcessingContext import ProcessingContext + self._session._pc = ProcessingContext(self._session) self._session._pc.apply(self) - return self.data.get('cube', None) + return self.data.get("cube", None) raise ValueError( - "This Measurement does not have a cube saved. Consider reprocessing with a Processing Context.") + "This Measurement does not have a cube saved. Consider reprocessing with a Processing Context." + ) @property def thumbnail(self): @@ -266,7 +286,8 @@ def thumbnail(self): def capabilities(self) -> Capabilities: _ptr = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_measurement_get_capabilities( - self._handle, _ptr): + self._handle, _ptr + ): raise SDKException() return Capabilities(cuvis_il.p_int_value(_ptr)) @@ -282,21 +303,21 @@ def data_count(self) -> int: return cuvis_il.p_int_value(out) def clear_cube(self) -> None: - if cuvis_il.status_ok != cuvis_il.cuvis_measurement_clear_cube( - self._handle): + if cuvis_il.status_ok != cuvis_il.cuvis_measurement_clear_cube(self._handle): raise SDKException() pass def clear_implicit_reference(self, ref_type: ReferenceType) -> None: - if cuvis_il.status_ok != \ - cuvis_il.cuvis_measurement_clear_implicit_reference( - self._handle, internal.__CuvisReferenceType__[ref_type]): + if cuvis_il.status_ok != cuvis_il.cuvis_measurement_clear_implicit_reference( + self._handle, internal.__CuvisReferenceType__[ref_type] + ): raise SDKException() def deepcopy(self): _ptr = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_measurement_deep_copy( - self._handle, _ptr): + self._handle, _ptr + ): raise SDKException() copy = Measurement(cuvis_il.p_int_value(_ptr)) return copy @@ -313,5 +334,5 @@ def __deepcopy__(self, memo): return self.deepcopy() def __copy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Shallow copying is not supported for Measurement') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for Measurement") diff --git a/cuvis/SessionFile.py b/cuvis/SessionFile.py index 8b842a8..e8a7ef1 100644 --- a/cuvis/SessionFile.py +++ b/cuvis/SessionFile.py @@ -1,10 +1,14 @@ - from pathlib import Path from ._cuvis_il import cuvis_il from .Measurement import Measurement, ImageData from .cuvis_aux import SDKException -from .cuvis_types import OperationMode, SessionItemType, ReferenceType, CUVIS_imbuffer_format +from .cuvis_types import ( + OperationMode, + SessionItemType, + ReferenceType, + CUVIS_imbuffer_format, +) import cuvis.cuvis_types as internal @@ -18,18 +22,19 @@ def __init__(self, base: Union[Path, str]): self._pc = None if base.exists(): _ptr = cuvis_il.new_p_int() - if cuvis_il.status_ok != cuvis_il.cuvis_session_file_load(str(base), - _ptr): + if cuvis_il.status_ok != cuvis_il.cuvis_session_file_load(str(base), _ptr): raise SDKException() self._handle = cuvis_il.p_int_value(_ptr) else: - raise FileNotFoundError( - "Could not open SessionFile File! File not found!") + raise FileNotFoundError("Could not open SessionFile File! File not found!") - def get_measurement(self, frameNo: int = 0, itemtype: SessionItemType = SessionItemType.no_gaps) -> Optional[Measurement]: + def get_measurement( + self, frameNo: int = 0, itemtype: SessionItemType = SessionItemType.no_gaps + ) -> Optional[Measurement]: _ptr = cuvis_il.new_p_int() - ret = cuvis_il.cuvis_session_file_get_mesu(self._handle, frameNo, internal.__CuvisSessionItemType__[itemtype], - _ptr) + ret = cuvis_il.cuvis_session_file_get_mesu( + self._handle, frameNo, internal.__CuvisSessionItemType__[itemtype], _ptr + ) if cuvis_il.status_no_measurement == ret: return None if cuvis_il.status_ok != ret: @@ -38,11 +43,13 @@ def get_measurement(self, frameNo: int = 0, itemtype: SessionItemType = SessionI mesu._session = self return mesu - def get_reference(self, frameNo: int, reftype: ReferenceType) -> Optional[Measurement]: + def get_reference( + self, frameNo: int, reftype: ReferenceType + ) -> Optional[Measurement]: _ptr = cuvis_il.new_p_int() ret = cuvis_il.cuvis_session_file_get_reference_mesu( - self._handle, frameNo, internal.__CuvisReferenceType__[reftype], - _ptr) + self._handle, frameNo, internal.__CuvisReferenceType__[reftype], _ptr + ) if cuvis_il.status_no_measurement == ret: return None if cuvis_il.status_ok != ret: @@ -52,27 +59,30 @@ def get_reference(self, frameNo: int, reftype: ReferenceType) -> Optional[Measur @property def thumbnail(self) -> ImageData: thumbnail_data = cuvis_il.cuvis_view_data_t() - if cuvis_il.status_ok != cuvis_il.cuvis_session_file_get_thumbnail(self, thumbnail_data): + if cuvis_il.status_ok != cuvis_il.cuvis_session_file_get_thumbnail( + self, thumbnail_data + ): raise SDKException() if thumbnail_data.data.format == CUVIS_imbuffer_format["imbuffer_format_uint8"]: - return ImageData(img_buf=thumbnail_data.data, - dformat=thumbnail_data.data.format) + return ImageData( + img_buf=thumbnail_data.data, dformat=thumbnail_data.data.format + ) else: raise SDKException("Unsupported viewer bit depth!") def get_size(self, itemtype: SessionItemType = SessionItemType.no_gaps) -> int: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_session_file_get_size( - self._handle, internal.__CuvisSessionItemType__[itemtype], val): + self._handle, internal.__CuvisSessionItemType__[itemtype], val + ): raise SDKException() return cuvis_il.p_int_value(val) @property def fps(self) -> float: val = cuvis_il.new_p_double() - if cuvis_il.status_ok != cuvis_il.cuvis_session_file_get_fps( - self._handle, val): + if cuvis_il.status_ok != cuvis_il.cuvis_session_file_get_fps(self._handle, val): raise SDKException() return cuvis_il.p_double_value(val) @@ -80,7 +90,8 @@ def fps(self) -> float: def operation_mode(self) -> OperationMode: val = cuvis_il.new_p_cuvis_operation_mode_t() if cuvis_il.status_ok != cuvis_il.cuvis_session_file_get_operation_mode( - self._handle, val): + self._handle, val + ): raise SDKException() return internal.__OperationMode__[cuvis_il.p_cuvis_operation_mode_t_value(val)] @@ -107,9 +118,9 @@ def __del__(self): self._handle = cuvis_il.p_int_value(_ptr) def __deepcopy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Deep copying is not supported for SessionFile') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Deep copying is not supported for SessionFile") def __copy__(self): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Shallow copying is not supported for SessionFile') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for SessionFile") diff --git a/cuvis/Viewer.py b/cuvis/Viewer.py index c92e122..124c508 100644 --- a/cuvis/Viewer.py +++ b/cuvis/Viewer.py @@ -1,7 +1,6 @@ from ._cuvis_il import cuvis_il from .Measurement import ImageData, Measurement from .cuvis_aux import SDKException -from .cuvis_types import CUVIS_imbuffer_format from .FileWriteSettings import ViewerSettings @@ -16,20 +15,22 @@ def __init__(self, settings: Union[int, ViewerSettings]): elif isinstance(settings, ViewerSettings): _ptr = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_viewer_create( - _ptr, settings._get_internal()): + _ptr, settings._get_internal() + ): raise SDKException() self._handle = cuvis_il.p_int_value(_ptr) else: raise SDKException( - "Could not open ViewerSettings of type {}!".format( - type(settings))) + "Could not open ViewerSettings of type {}!".format(type(settings)) + ) pass - def _create_view_data(self, new_handle: int) -> Union[dict[str, ImageData], ImageData]: + def _create_view_data( + self, new_handle: int + ) -> Union[dict[str, ImageData], ImageData]: _ptr = cuvis_il.new_p_int() - if cuvis_il.status_ok != cuvis_il.cuvis_view_get_data_count( - new_handle, _ptr): + if cuvis_il.status_ok != cuvis_il.cuvis_view_get_data_count(new_handle, _ptr): raise SDKException() dataCount = cuvis_il.p_int_value(_ptr) @@ -39,11 +40,13 @@ def _create_view_data(self, new_handle: int) -> Union[dict[str, ImageData], Imag for i in range(dataCount): view_data = cuvis_il.cuvis_view_data_t() if cuvis_il.status_ok != cuvis_il.cuvis_view_get_data( - new_handle, i, view_data): + new_handle, i, view_data + ): raise SDKException() - view_array[view_data.id] = ImageData(img_buf=view_data.data, - dformat=view_data.data.format) + view_array[view_data.id] = ImageData( + img_buf=view_data.data, dformat=view_data.data.format + ) if len(view_array.keys()) == 1: # if only one value is available, do not wrap in dictionary @@ -53,8 +56,9 @@ def _create_view_data(self, new_handle: int) -> Union[dict[str, ImageData], Imag def apply(self, mesu: Measurement) -> Union[dict[str, ImageData], ImageData]: _ptr = cuvis_il.new_p_int() - if cuvis_il.status_ok != cuvis_il.cuvis_viewer_apply(self._handle, - mesu._handle, _ptr): + if cuvis_il.status_ok != cuvis_il.cuvis_viewer_apply( + self._handle, mesu._handle, _ptr + ): raise SDKException() currentView = cuvis_il.p_int_value(_ptr) @@ -67,9 +71,9 @@ def __del__(self): self._handle = cuvis_il.p_int_value(_ptr) def __deepcopy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Deep copying is not supported for Viewer') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Deep copying is not supported for Viewer") def __copy__(self): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Shallow copying is not supported for Viewer') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for Viewer") diff --git a/cuvis/Worker.py b/cuvis/Worker.py index b925d8f..1d18a7c 100644 --- a/cuvis/Worker.py +++ b/cuvis/Worker.py @@ -6,7 +6,6 @@ from .ProcessingContext import ProcessingContext from .Export import Exporter from .SessionFile import SessionFile -from .Measurement import Measurement from .FileWriteSettings import WorkerSettings import asyncio as a @@ -44,12 +43,14 @@ def __init__(self, args: WorkerSettings): def set_acquisition_context(self, base: AcquisitionContext = None) -> None: if base is not None: if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_acq_cont( - self._handle, base._handle): + self._handle, base._handle + ): raise SDKException() self._acquisition_set = True else: if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_acq_cont( - self._handle, 0): + self._handle, 0 + ): raise SDKException() self._acquisition_set = False pass @@ -58,12 +59,14 @@ def set_acquisition_context(self, base: AcquisitionContext = None) -> None: def set_processing_context(self, base: ProcessingContext = None) -> None: if base is not None: if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_proc_cont( - self._handle, base._handle): + self._handle, base._handle + ): raise SDKException() self._processing_set = True else: if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_proc_cont( - self._handle, 0): + self._handle, 0 + ): raise SDKException() self._processing_set = False pass @@ -72,12 +75,14 @@ def set_processing_context(self, base: ProcessingContext = None) -> None: def set_exporter(self, base: Exporter = None) -> None: if base is not None: if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_exporter( - self._handle, base._handle): + self._handle, base._handle + ): raise SDKException() self._exporter_set = True else: if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_exporter( - self._handle, 0): + self._handle, 0 + ): raise SDKException() self._exporter_set = False pass @@ -86,26 +91,30 @@ def set_exporter(self, base: Exporter = None) -> None: def set_viewer(self, base: Viewer = None) -> None: if base is not None: if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_viewer( - self._handle, base._handle): + self._handle, base._handle + ): raise SDKException() self._viewer_set = True else: - if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_viewer( - self._handle, 0): + if cuvis_il.status_ok != cuvis_il.cuvis_worker_set_viewer(self._handle, 0): raise SDKException() self._viewer_set = False pass @copydoc(cuvis_il.cuvis_worker_ingest_session_file) - def ingest_session_file(self, session: SessionFile, frame_selection: str = 'all') -> None: + def ingest_session_file( + self, session: SessionFile, frame_selection: str = "all" + ) -> None: if cuvis_il.status_ok != cuvis_il.cuvis_worker_ingest_session_file( - self._handle, session._handle, frame_selection): + self._handle, session._handle, frame_selection + ): raise SDKException() @copydoc(cuvis_il.cuvis_worker_ingest_mesu) def ingest_mesu(self, mesu: Measurement) -> None: if cuvis_il.status_ok != cuvis_il.cuvis_worker_ingest_mesu( - self._handle, mesu._handle): + self._handle, mesu._handle + ): raise SDKException() pass @@ -113,9 +122,9 @@ def ingest_mesu(self, mesu: Measurement) -> None: @copydoc(cuvis_il.cuvis_worker_query_session_progress) def query_session_progress(self) -> float: val = cuvis_il.new_p_double() - if cuvis_il.status_ok != \ - cuvis_il.cuvis_worker_query_session_progress(self._handle, - val): + if cuvis_il.status_ok != cuvis_il.cuvis_worker_query_session_progress( + self._handle, val + ): raise SDKException() return cuvis_il.p_double_value(val) @@ -123,7 +132,8 @@ def query_session_progress(self) -> float: def has_next_result(self) -> bool: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_has_next_result( - self._handle, val): + self._handle, val + ): raise SDKException() return cuvis_il.p_int_value(val) != 0 @@ -132,7 +142,8 @@ def get_next_result(self, timeout) -> WorkerResult: ptr_mesu = cuvis_il.new_p_int() ptr_view = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_next_result( - self._handle, ptr_mesu, ptr_view, timeout): + self._handle, ptr_mesu, ptr_view, timeout + ): raise SDKException() mesu = Measurement(cuvis_il.p_int_value(ptr_mesu)) if self._viewer_set: @@ -151,7 +162,8 @@ async def get_next_result_async(self, timeout: int) -> WorkerResult: if self.has_next_result(): await a.sleep(0) if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_next_result( - self._handle, ptr_mesu, ptr_view, 100): + self._handle, ptr_mesu, ptr_view, 100 + ): raise SDKException() break else: @@ -169,7 +181,8 @@ async def get_next_result_async(self, timeout: int) -> WorkerResult: def input_queue_limit(self) -> int: val = cuvis_il.new_p_ulong() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_input_queue_limit( - self._handle, val): + self._handle, val + ): raise SDKException() return cuvis_il.p_ulong_value(val) @@ -178,7 +191,8 @@ def input_queue_limit(self) -> int: def mandatory_queue_limit(self) -> int: val = cuvis_il.new_p_ulong() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_mandatory_queue_limit( - self._handle, val): + self._handle, val + ): raise SDKException() return cuvis_il.p_ulong_value(val) @@ -187,7 +201,8 @@ def mandatory_queue_limit(self) -> int: def supplementary_queue_limit(self) -> int: val = cuvis_il.new_p_ulong() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_supplementary_queue_limit( - self._handle, val): + self._handle, val + ): raise SDKException() return cuvis_il.p_ulong_value(val) @@ -196,7 +211,8 @@ def supplementary_queue_limit(self) -> int: def output_queue_limit(self) -> int: val = cuvis_il.new_p_ulong() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_output_queue_limit( - self._handle, val): + self._handle, val + ): raise SDKException() return cuvis_il.p_ulong_value(val) @@ -205,7 +221,8 @@ def output_queue_limit(self) -> int: def queue_used(self) -> int: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_queue_used( - self._handle, val): + self._handle, val + ): raise SDKException() return cuvis_il.p_int_value(val) @@ -214,7 +231,8 @@ def queue_used(self) -> int: def can_drop_results(self) -> bool: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_can_drop_results( - self._handle, val): + self._handle, val + ): raise SDKException() return bool(cuvis_il.p_int_value(val)) @@ -223,7 +241,8 @@ def can_drop_results(self) -> bool: def can_skip_measurements(self) -> bool: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_can_skip_measurements( - self._handle, val): + self._handle, val + ): raise SDKException() return bool(cuvis_il.p_int_value(val)) @@ -232,7 +251,8 @@ def can_skip_measurements(self) -> bool: def can_skip_supplementary(self) -> bool: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_can_skip_supplementary( - self._handle, val): + self._handle, val + ): raise SDKException() return bool(cuvis_il.p_int_value(val)) @@ -241,7 +261,8 @@ def can_skip_supplementary(self) -> bool: def is_processing_mandatory(self) -> bool: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_is_processing_mandatory( - self._handle, val): + self._handle, val + ): raise SDKException() return bool(cuvis_il.p_int_value(val)) @@ -249,8 +270,7 @@ def is_processing_mandatory(self) -> bool: @copydoc(cuvis_il.cuvis_worker_is_processing) def is_processing(self) -> bool: val = cuvis_il.new_p_int() - if cuvis_il.status_ok != cuvis_il.cuvis_worker_is_processing( - self._handle, val): + if cuvis_il.status_ok != cuvis_il.cuvis_worker_is_processing(self._handle, val): raise SDKException() return bool(cuvis_il.p_int_value(val)) @@ -259,7 +279,8 @@ def is_processing(self) -> bool: def threads_busy(self) -> int: val = cuvis_il.new_p_int() if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_threads_busy( - self._handle, val): + self._handle, val + ): raise SDKException() return cuvis_il.p_int_value(val) @@ -267,30 +288,28 @@ def threads_busy(self) -> int: @copydoc(cuvis_il.cuvis_worker_get_state) def state(self) -> WorkerState: val = cuvis_il.cuvis_worker_state_t() - if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_state( - self._handle, val): + if cuvis_il.status_ok != cuvis_il.cuvis_worker_get_state(self._handle, val): raise SDKException() return WorkerState._from_internal(val) @copydoc(cuvis_il.cuvis_worker_start) def start_processing(self) -> None: - if cuvis_il.status_ok != cuvis_il.cuvis_worker_start( - self._handle): + if cuvis_il.status_ok != cuvis_il.cuvis_worker_start(self._handle): raise SDKException() @copydoc(cuvis_il.cuvis_worker_stop) def stop_processing(self) -> None: - if cuvis_il.status_ok != cuvis_il.cuvis_worker_stop( - self._handle): + if cuvis_il.status_ok != cuvis_il.cuvis_worker_stop(self._handle): raise SDKException() @copydoc(cuvis_il.cuvis_worker_drop_all_queued) def drop_all_queued(self) -> None: - if cuvis_il.status_ok != cuvis_il.cuvis_worker_drop_all_queued( - self._handle): + if cuvis_il.status_ok != cuvis_il.cuvis_worker_drop_all_queued(self._handle): raise SDKException() - def register_worker_callback(self, callback: Callable[[WorkerResult], Awaitable[None]]) -> None: + def register_worker_callback( + self, callback: Callable[[WorkerResult], Awaitable[None]] + ) -> None: self.reset_worker_callback() poll_time = 0.001 @@ -298,7 +317,7 @@ async def _internal_worker_loop(): while True: if self.has_next_result(): workerContainer = await self.get_next_result_async(1000) - task = a.create_task(callback(workerContainer)) + a.create_task(callback(workerContainer)) # TODO limit number of created task objects like in the cpp wrapper else: @@ -319,9 +338,9 @@ def __del__(self): self._handle = cuvis_il.p_int_value(_ptr) def __deepcopy__(self, memo): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Deep copying is not supported for Worker') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Deep copying is not supported for Worker") def __copy__(self): - '''This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.''' - raise TypeError('Shallow copying is not supported for Worker') + """This functions is not permitted due to the class only keeping a handle, that is managed by the cuvis sdk.""" + raise TypeError("Shallow copying is not supported for Worker") diff --git a/cuvis/_cuvis_il.py b/cuvis/_cuvis_il.py index 0a5707d..a0692b2 100644 --- a/cuvis/_cuvis_il.py +++ b/cuvis/_cuvis_il.py @@ -1,6 +1,6 @@ try: - from cuvis_il import cuvis_il # type: ignore + from cuvis_il import cuvis_il # type: ignore except ImportError as e: - if e.msg.startswith('DLL'): + if e.msg.startswith("DLL"): raise - import cuvis_il # type: ignore \ No newline at end of file + import cuvis_il # type: ignore diff --git a/cuvis/cube_utils.py b/cuvis/cube_utils.py index 117239c..9ecf9f5 100644 --- a/cuvis/cube_utils.py +++ b/cuvis/cube_utils.py @@ -106,7 +106,8 @@ def __init__(self, img_buf=None, dformat=None): if not isinstance(img_buf, cuvis_il.cuvis_imbuffer_t): raise TypeError( - "Wrong data type for image buffer: {}".format(type(img_buf))) + "Wrong data type for image buffer: {}".format(type(img_buf)) + ) if dformat is None: raise TypeError("Missing format for reading image buffer") @@ -126,7 +127,8 @@ def __init__(self, img_buf=None, dformat=None): if img_buf.wavelength is not None: self.wavelength = [ cuvis_il.p_unsigned_int_getitem(img_buf.wavelength, z) - for z in range(self.channels)] + for z in range(self.channels) + ] @property def shape(self) -> Optional[tuple]: @@ -172,16 +174,22 @@ def spectrum(self) -> np.ndarray: if not self.is_spectrum: raise ValueError( "Not a single pixel measurement ({}x{}); index a pixel first, " - "for example image[y, x].".format(self.shape[1], self.shape[0])) + "for example image[y, x].".format(self.shape[1], self.shape[0]) + ) return self.array.reshape(-1) def __repr__(self) -> str: if self.array is None: return "ImageData(empty)" return "ImageData({}x{}x{}, {}, wavelength={})".format( - self.width, self.height, self.channels, self.array.dtype, - "no" if self.wavelength is None else - "{}..{} nm".format(self.wavelength[0], self.wavelength[-1])) + self.width, + self.height, + self.channels, + self.array.dtype, + "no" + if self.wavelength is None + else "{}..{} nm".format(self.wavelength[0], self.wavelength[-1]), + ) def __getitem__(self, key) -> Union[np.ndarray, tuple, "ImageData", np.generic]: """ @@ -227,7 +235,8 @@ def __getitem__(self, key) -> Union[np.ndarray, tuple, "ImageData", np.generic]: if np.ndim(sliced_array) == 3: return ImageData.from_array( sliced_array, - wavelength=self._wavelengths_at(bands, sliced_array.shape[-1])) + wavelength=self._wavelengths_at(bands, sliced_array.shape[-1]), + ) if np.ndim(sliced_array) == 1: return sliced_array, self._wavelengths_at(bands, len(sliced_array)) return sliced_array @@ -243,9 +252,11 @@ def _band_indices(self, key) -> Optional[Sequence[int]]: key = (key,) at_ellipsis = next((i for i, part in enumerate(key) if part is Ellipsis), None) if at_ellipsis is not None: - key = (key[:at_ellipsis] - + (slice(None),) * (4 - len(key)) - + key[at_ellipsis + 1:]) + key = ( + key[:at_ellipsis] + + (slice(None),) * (4 - len(key)) + + key[at_ellipsis + 1 :] + ) band_key = key[2] if len(key) >= 3 else slice(None) if isinstance(band_key, slice): @@ -259,8 +270,9 @@ def _band_indices(self, key) -> Optional[Sequence[int]]: return [int(band) % self.channels for band in selected.ravel()] return None - def _wavelengths_at(self, bands: Optional[Sequence[int]], - expected: int) -> Optional[list]: + def _wavelengths_at( + self, bands: Optional[Sequence[int]], expected: int + ) -> Optional[list]: """The wavelengths of the selected bands, or None if they do not line up.""" if self.wavelength is None or bands is None or len(bands) != expected: return None @@ -269,10 +281,14 @@ def _wavelengths_at(self, bands: Optional[Sequence[int]], def _wrap(self, result): """Keep the metadata when an operation preserved the image geometry.""" # A boolean result is a mask, not image data; wavelengths would not describe it. - if (isinstance(result, np.ndarray) and result.shape == self.array.shape - and result.dtype != bool): + if ( + isinstance(result, np.ndarray) + and result.shape == self.array.shape + and result.dtype != bool + ): return ImageData.from_array( - result, self.width, self.height, self.channels, self.wavelength) + result, self.width, self.height, self.channels, self.wavelength + ) return result def __array__(self, dtype=None, copy=None) -> np.ndarray: @@ -304,7 +320,8 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): if "out" in kwargs: kwargs["out"] = tuple(_unwrap(out) for out in kwargs["out"]) return self._wrap( - getattr(ufunc, method)(*(_unwrap(i) for i in inputs), **kwargs)) + getattr(ufunc, method)(*(_unwrap(i) for i in inputs), **kwargs) + ) def to_numpy(self) -> np.ndarray: """ @@ -317,8 +334,14 @@ def to_numpy(self) -> np.ndarray: return self.array @classmethod - def from_array(cls, array: np.ndarray, width: int = None, height: int = None, - channels: int = None, wavelength=None): + def from_array( + cls, + array: np.ndarray, + width: int = None, + height: int = None, + channels: int = None, + wavelength=None, + ): """ Build an :class:`ImageData` around an existing array. @@ -360,7 +383,9 @@ def _binary_op(op, reflected=False): """Build one arithmetic dunder that delegates to the underlying array.""" def apply(self, other): - operands = (_unwrap(other), self.array) if reflected else (self.array, _unwrap(other)) + operands = ( + (_unwrap(other), self.array) if reflected else (self.array, _unwrap(other)) + ) return self._wrap(op(*operands)) name = op.__name__.strip("_") @@ -368,14 +393,22 @@ def apply(self, other): apply.__doc__ = ( "Element-wise {} on the underlying array, against a scalar, an array or " "another ImageData. Returns an ImageData with the metadata carried over " - "when the geometry is unchanged, otherwise the plain NumPy result.".format(name)) + "when the geometry is unchanged, otherwise the plain NumPy result.".format(name) + ) return apply # Arithmetic operators, so that measurements can be combined directly rather than # through their arrays. Comparisons are deliberately left out; use np.asarray(). -for _op in (operator.add, operator.sub, operator.mul, operator.truediv, - operator.floordiv, operator.mod, operator.pow): +for _op in ( + operator.add, + operator.sub, + operator.mul, + operator.truediv, + operator.floordiv, + operator.mod, + operator.pow, +): for _reflected in (False, True): _method = _binary_op(_op, _reflected) setattr(ImageData, _method.__name__, _method) diff --git a/cuvis/cuvis_aux.py b/cuvis/cuvis_aux.py index 1f71a1b..6aa1262 100644 --- a/cuvis/cuvis_aux.py +++ b/cuvis/cuvis_aux.py @@ -1,10 +1,9 @@ - from dataclasses import dataclass import cuvis.cuvis_types as internal -from typing import Union from ._cuvis_il import cuvis_il import logging import datetime + base_datetime = datetime.datetime(1970, 1, 1) @@ -19,12 +18,10 @@ def _fn_bits(n): def _bit_translate(n, translate_dict): flags = _fn_bits(n) - return [key for key, vald in translate_dict.items() - if vald in flags] + return [key for key, vald in translate_dict.items() if vald in flags] class SDKException(Exception): - def __init__(self, *args): if len(args) == 0: self.message = cuvis_il.cuvis_get_last_error_msg() @@ -42,9 +39,9 @@ class SessionData(object): sequence_number: int def __repr__(self): - return "'SessionFile: {}; no. {}, seq. {}'".format(self.name, - self.session_number, - self.sequence_number) + return "'SessionFile: {}; no. {}, seq. {}'".format( + self.name, self.session_number, self.sequence_number + ) @dataclass(frozen=True) @@ -70,7 +67,7 @@ def __repr__(self): self.file_path, self.cube_width, self.cube_height, - self.cube_channels + self.cube_channels, ) @classmethod @@ -86,7 +83,8 @@ def _from_internal(cls, ci: cuvis_il.cuvis_calibration_info_t): ci.cube_width, ci.cube_height, ci.cube_channels, - wls) + wls, + ) @dataclass(frozen=True) @@ -98,16 +96,17 @@ class GPSData(object): def __repr__(self): return "'GPS: lon./lat.: {} / {}; alt. {}, time {}'".format( - self.longitude, self.latitude, self.altitude, - self.time) + self.longitude, self.latitude, self.altitude, self.time + ) @classmethod def _from_internal(cls, gps): - return cls(longitude=gps.longitude, - latitude=gps.latitude, - altitude=gps.altitude, - time=base_datetime + datetime.timedelta( - milliseconds=gps.time)) + return cls( + longitude=gps.longitude, + latitude=gps.latitude, + altitude=gps.altitude, + time=base_datetime + datetime.timedelta(milliseconds=gps.time), + ) @dataclass(frozen=True) @@ -124,16 +123,18 @@ class SensorInfo(object): @classmethod def _from_internal(cls, info): - return cls(averages=info.averages, - temperature=info.temperature, - gain=info.gain, - readout_time=base_datetime + datetime.timedelta( - milliseconds=info.readout_time), - width=info.width, - height=info.height, - raw_frame_id=info.raw_frame_id, - pixel_format=info.pixel_format, - integration_time=info.integration_time) + return cls( + averages=info.averages, + temperature=info.temperature, + gain=info.gain, + readout_time=base_datetime + + datetime.timedelta(milliseconds=info.readout_time), + width=info.width, + height=info.height, + raw_frame_id=info.raw_frame_id, + pixel_format=info.pixel_format, + integration_time=info.integration_time, + ) @dataclass(frozen=True) @@ -148,13 +149,15 @@ class WorkerState(object): @classmethod def _from_internal(cls, state): - return cls(measurementsInQueue=state.measurementsInQueue, - sessionFilesInQueue=state.sessionFilesInQueue, - framesInQueue=state.framesInQueue, - measurementsBeingProcessed=state.measurementsBeingProcessed, - resultsInQueue=state.resultsInQueue, - hasAcquisitionContext=bool(state.hasAcquisitionContext), - isProcessing=bool(state.isProcessing)) + return cls( + measurementsInQueue=state.measurementsInQueue, + sessionFilesInQueue=state.sessionFilesInQueue, + framesInQueue=state.framesInQueue, + measurementsBeingProcessed=state.measurementsBeingProcessed, + resultsInQueue=state.resultsInQueue, + hasAcquisitionContext=bool(state.hasAcquisitionContext), + isProcessing=bool(state.isProcessing), + ) @dataclass(frozen=True) @@ -167,11 +170,13 @@ class ComponentInfo(object): @classmethod def _from_internal(cls, ci): - return cls(type=internal.__ComponentType__[ci.type], - display_name=ci.displayname, - sensor_info=ci.sensorinfo, - user_field=ci.userfield, - pixel_format=ci.pixelformat) + return cls( + type=internal.__ComponentType__[ci.type], + display_name=ci.displayname, + sensor_info=ci.sensorinfo, + user_field=ci.userfield, + pixel_format=ci.pixelformat, + ) class Bitset(object): @@ -180,38 +185,38 @@ class Bitset(object): @classmethod def supremum(cls): - """"Returns a bitset containing all possible members of the current Bitset class""" + """ "Returns a bitset containing all possible members of the current Bitset class""" return cls(sum([v for k, v in cls._translation_dict.items()])) def all(self): - """"Returns a bitset containing all possible members of the current Bitset class""" + """ "Returns a bitset containing all possible members of the current Bitset class""" return type(self).supremum() def __init__(self, value): self._value = value def strings(self) -> list[str]: - """"Returns a list containing the string values of the current members of the Bitset""" + """ "Returns a list containing the string values of the current members of the Bitset""" return _bit_translate(self._value, type(self)._translation_dict) def __repr__(self): - """"Returns the string representation of the current Bitset""" - return f'{self.__class__.__name__}({self.strings()})' + """ "Returns the string representation of the current Bitset""" + return f"{self.__class__.__name__}({self.strings()})" def __int__(self): - """"Returns the internal integer value of the current Bitset """ + """ "Returns the internal integer value of the current Bitset""" return self._value def __len__(self): - """"Returns the amount of members of the current Bitset """ - return bin(self._value).count('1') + """ "Returns the amount of members of the current Bitset""" + return bin(self._value).count("1") def __iter__(self): - """"Returns an iterator over the string values of the current member of the Bitset """ + """ "Returns an iterator over the string values of the current member of the Bitset""" return _bit_translate(self._value, type(self)._translation_dict).__iter__() def __contains__(self, member): - """"Returns True if the input value is part of the set. The value can be a string, an int or a similiar Bitset instance """ + """ "Returns True if the input value is part of the set. The value can be a string, an int or a similiar Bitset instance""" if isinstance(member, str): return type(self)._translation_dict[member] & self._value elif isinstance(member, int): @@ -219,11 +224,11 @@ def __contains__(self, member): elif isinstance(member, type(self)): return (member & self._value) == member else: - raise ValueError(f'Cannot call operator with type {type(member)}') + raise ValueError(f"Cannot call operator with type {type(member)}") @classmethod def from_strings(cls, *values: list[str]): - """" Creates a Bitset from a list of strings """ + """ " Creates a Bitset from a list of strings""" return cls(sum([cls._translation_dict[v] for v in values])) diff --git a/cuvis/cuvis_types.py b/cuvis/cuvis_types.py index e7dd417..2c1a1d7 100644 --- a/cuvis/cuvis_types.py +++ b/cuvis/cuvis_types.py @@ -140,10 +140,10 @@ class PanSharpeningAlgorithm(Enum): __CuvisPanSharpeningAlgorithm__ = { - PanSharpeningAlgorithm.Noop : cuvis_il.pan_sharpening_algorithm_Noop, - PanSharpeningAlgorithm.CubertMacroPixel : cuvis_il.pan_sharpening_algorithm_CubertMacroPixel, - PanSharpeningAlgorithm.CubertPanRatio : cuvis_il.pan_sharpening_algorithm_CubertPanRatio, - PanSharpeningAlgorithm.PCAFusion : cuvis_il.pan_sharpening_algorithm_PCAFusion + PanSharpeningAlgorithm.Noop: cuvis_il.pan_sharpening_algorithm_Noop, + PanSharpeningAlgorithm.CubertMacroPixel: cuvis_il.pan_sharpening_algorithm_CubertMacroPixel, + PanSharpeningAlgorithm.CubertPanRatio: cuvis_il.pan_sharpening_algorithm_CubertPanRatio, + PanSharpeningAlgorithm.PCAFusion: cuvis_il.pan_sharpening_algorithm_PCAFusion, } __PanSharpeningAlgorithm__ = __inverseTranslationDict(__CuvisPanSharpeningAlgorithm__) diff --git a/cuvis/doc.py b/cuvis/doc.py index 6dec40e..dd852e1 100644 --- a/cuvis/doc.py +++ b/cuvis/doc.py @@ -1,20 +1,20 @@ # taken from https://stackoverflow.com/questions/68901049/copying-the-docstring-of-function-onto-another-function-by-name from typing import Callable, TypeVar, Any + try: - from typing_extensions import ParamSpec, TypeAlias # type: ignore -except ImportError as exc: + from typing_extensions import ParamSpec, TypeAlias # type: ignore +except ImportError: from typing import ParamSpec, TypeAlias - -T = TypeVar('T') -P = ParamSpec('P') +T = TypeVar("T") +P = ParamSpec("P") WrappedFuncDeco: TypeAlias = Callable[[Callable[P, T]], Callable[P, T]] def copydoc(copy_func: Callable[..., Any]) -> WrappedFuncDeco[P, T]: - """Copies the doc string of the given function to another. + """Copies the doc string of the given function to another. This function is intended to be used as a decorator. .. code-block:: python3 @@ -32,4 +32,4 @@ def wrapped(func: Callable[P, T]) -> Callable[P, T]: func.__doc__ = copy_func.__doc__ return func - return wrapped \ No newline at end of file + return wrapped diff --git a/git-hash.txt b/git-hash.txt deleted file mode 100644 index 7762dd1..0000000 --- a/git-hash.txt +++ /dev/null @@ -1 +0,0 @@ -cee09c1ca36782c660607b93c445c42befdcf8a8 diff --git a/prebuild.py b/prebuild.py index 027df7d..0e792c5 100644 --- a/prebuild.py +++ b/prebuild.py @@ -13,7 +13,7 @@ def get_git_commit_hash(): return "unknown" -with open(Path(__file__).parent / "git-hash.txt", "w") as f: +with open(Path(__file__).parent / "cuvis" / "git-hash.txt", "w") as f: f.write(f"{get_git_commit_hash()}\n") -print("git-hash.txt created.") +print("cuvis/git-hash.txt created.") diff --git a/pyproject.toml b/pyproject.toml index 7709951..b438a4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,19 @@ test = [ "pytest-timeout>=2.1.0", "pytest-xdist>=3.0.0", ] +dev = [ + "ruff==0.16.3", +] + +[tool.ruff] +target-version = "py39" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] + +[tool.ruff.lint.per-file-ignores] +# Re-export surfaces: the imports are the public API, not dead code. +"cuvis/__init__.py" = ["F401"] +"cuvis/_cuvis_il.py" = ["F401"] +# Dataclass fields that are shadowed by a property/setter pair further down. +"cuvis/FileWriteSettings.py" = ["F811"] diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100644 index 0000000..b65ad58 --- /dev/null +++ b/scripts/check_changelog.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Validate CHANGELOG.md against the conventions in CONTRIBUTING.md. + +Without arguments the structure is checked. +With --tag the tag, the pyproject version and the newest release section must all agree. +With --extract the body of one release section is written to stdout, for the GitHub Release text. +""" + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +SECTIONS = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security") + +UNRELEASED = re.compile(r"^## \[Unreleased\]$") +RELEASE = re.compile(r"^## \[(\d+(?:\.\d+)*(?:\.post\d+)?)\] - (\d{4}-\d{2}-\d{2})$") +SECTION = re.compile(r"^### (.+)$") +BULLET = re.compile(r"^- \S") +CONTINUATION = re.compile(r"^ {2}\S") + + +def version_key(version): + """Order releases the way PEP 440 does, so 3.5.3 and 3.5.3.0 compare equal.""" + base, _, post = version.partition(".post") + padded = (tuple(int(p) for p in base.split(".")) + (0,) * 8)[:8] + return padded, int(post or 0) + + +def releases(lines): + """Yield (line_number, version, date) for every release header.""" + return ( + (no, *match.groups()) + for no, line in enumerate(lines, 1) + if (match := RELEASE.match(line)) + ) + + +def section_errors(no, heading, seen): + if heading not in SECTIONS: + yield f"{no}: unknown section '{heading}', expected one of {', '.join(SECTIONS)}" + elif heading in seen: + yield f"{no}: section '{heading}' appears twice in the same release" + elif seen and SECTIONS.index(heading) < max(SECTIONS.index(s) for s in seen): + yield f"{no}: section '{heading}' is out of order, expected {' < '.join(SECTIONS)}" + + +def structure_errors(lines): + """Report every convention violation, one message per offending line.""" + if not any(map(UNRELEASED.match, lines)): + yield "0: no '## [Unreleased]' section; add one so the next change has a home" + + versions = [(no, v) for no, v, _ in releases(lines)] + for (_, previous), (no, version) in zip(versions, versions[1:]): + if version_key(version) == version_key(previous): + yield f"{no}: version {version} duplicates {previous} (equal under PEP 440)" + elif version_key(version) > version_key(previous): + yield f"{no}: version {version} must sort below {previous}" + + seen = set() + in_section = False + for no, line in enumerate(lines, 1): + if line.startswith("## "): + if not (UNRELEASED.match(line) or RELEASE.match(line)): + yield f"{no}: release header must be '## [] - '" + seen, in_section = set(), False + elif match := SECTION.match(line): + yield from section_errors(no, match.group(1), seen) + if match.group(1) in SECTIONS: + seen.add(match.group(1)) + in_section = True + elif ( + in_section + and line.strip() + and not (BULLET.match(line) or CONTINUATION.match(line)) + ): + yield f"{no}: expected a '- ' bullet or a two-space continuation line" + + +def body(lines, version): + """The lines of one release section, without its header.""" + starts = [ + no for no, v, _ in releases(lines) if version_key(v) == version_key(version) + ] + if not starts: + raise SystemExit(f"no release section for version {version} in CHANGELOG.md") + rest = lines[starts[0] :] + end = next( + (i for i, line in enumerate(rest) if i and line.startswith("## ")), len(rest) + ) + return "\n".join(rest[1:end]).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) + parser.add_argument("--pyproject", type=Path, default=Path("pyproject.toml")) + parser.add_argument( + "--tag", help="release tag (vX.Y.Z.W) that must match version and changelog" + ) + parser.add_argument( + "--extract", help="print the body of this release section and exit" + ) + args = parser.parse_args() + + lines = args.changelog.read_text(encoding="utf-8").splitlines() + + if args.extract: + print(body(lines, args.extract)) + return 0 + + errors = list(structure_errors(lines)) + + if args.tag: + version = tomllib.loads(args.pyproject.read_text(encoding="utf-8"))["project"][ + "version" + ] + newest = next((v for _, v, _ in releases(lines)), None) + if args.tag != f"v{version}": + errors.append( + f"0: tag {args.tag} does not match pyproject version {version}" + ) + if newest != version: + errors.append( + f"0: newest changelog release is {newest}, expected {version}" + ) + if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", version): + errors.append(f"0: version {version} is not MAJOR.MINOR.PATCH.TWEAK") + + for error in errors: + print(f"{args.changelog}:{error}", file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release_meta.py b/scripts/release_meta.py new file mode 100644 index 0000000..6b6fb28 --- /dev/null +++ b/scripts/release_meta.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Print the pyproject.toml facts the workflows need, as GITHUB_OUTPUT lines. + +Keeps the SDK container tag and the ruff pin derived from the single version +source instead of duplicated into the workflow YAML. +""" + +import tomllib +from pathlib import Path + +project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] +version = project["version"] + +facts = { + "version": version, + "sdk": ".".join(version.split(".")[:3]), + "ruff": next( + spec + for spec in project["optional-dependencies"]["dev"] + if spec.startswith("ruff") + ), +} + +print("\n".join(f"{key}={value}" for key, value in facts.items())) diff --git a/tests/test_acquisition.py b/tests/test_acquisition.py index 6a3cdf9..8831548 100644 --- a/tests/test_acquisition.py +++ b/tests/test_acquisition.py @@ -65,7 +65,9 @@ def test_acquisition_context_session_info(simulated_acquisition_context): @pytest.mark.slow -def test_simulated_capture_snapshot(simulated_acquisition_context, processing_context_from_session): +def test_simulated_capture_snapshot( + simulated_acquisition_context, processing_context_from_session +): """Test capturing snapshot in simulated mode.""" acq = simulated_acquisition_context @@ -89,7 +91,7 @@ def test_simulated_capture_snapshot(simulated_acquisition_context, processing_co pc = processing_context_from_session pc.processing_mode = cuvis.ProcessingMode.Raw pc.apply(mesu) - assert 'cube' in mesu.data + assert "cube" in mesu.data def test_acquisition_context_component_count(simulated_acquisition_context): diff --git a/tests/test_cube_utils.py b/tests/test_cube_utils.py index 1c6b89c..7cd175d 100644 --- a/tests/test_cube_utils.py +++ b/tests/test_cube_utils.py @@ -19,14 +19,16 @@ def spectrum(): """A single pixel measurement, as a point spectrometer delivers it.""" return ImageData.from_array( np.arange(2500, dtype=np.float32).reshape(1, 1, 2500), - wavelength=list(range(200, 2700))) + wavelength=list(range(200, 2700)), + ) @pytest.fixture def cube(): return ImageData.from_array( np.arange(4 * 3 * 5, dtype=np.uint16).reshape(4, 3, 5), - wavelength=[100, 200, 300, 400, 500]) + wavelength=[100, 200, 300, 400, 500], + ) @pytest.fixture @@ -137,8 +139,9 @@ def test_arithmetic_preserves_metadata(spectrum): assert doubled.wavelength == spectrum.wavelength np.testing.assert_array_equal(doubled.array, spectrum.array * 2) - np.testing.assert_array_equal((spectrum - spectrum).array, - np.zeros_like(spectrum.array)) + np.testing.assert_array_equal( + (spectrum - spectrum).array, np.zeros_like(spectrum.array) + ) np.testing.assert_array_equal((2 * spectrum).array, spectrum.array * 2) np.testing.assert_array_equal((-spectrum).array, -spectrum.array) @@ -179,7 +182,11 @@ def real_view(test_measurement): def test_real_cube_geometry_matches_its_metadata(real_cube): assert real_cube.array.ndim == 3 - assert real_cube.array.shape == (real_cube.height, real_cube.width, real_cube.channels) + assert real_cube.array.shape == ( + real_cube.height, + real_cube.width, + real_cube.channels, + ) assert not real_cube.is_spectrum @@ -213,8 +220,10 @@ def test_real_cube_index_forms(real_cube): assert real_cube[:, :, 0].shape == (real_cube.height, real_cube.width) assert np.ndim(real_cube[0, 0, 0]) == 0 assert real_cube[..., 1:3].wavelength == real_cube.wavelength[1:3] - assert real_cube[:, :, [0, 2]].wavelength == [real_cube.wavelength[0], - real_cube.wavelength[2]] + assert real_cube[:, :, [0, 2]].wavelength == [ + real_cube.wavelength[0], + real_cube.wavelength[2], + ] def test_real_view_has_no_wavelengths(real_view): @@ -234,8 +243,9 @@ def test_real_cube_arithmetic_preserves_metadata(real_cube): assert doubled.wavelength == real_cube.wavelength assert doubled.shape == real_cube.shape np.testing.assert_array_equal(doubled.array, real_cube.array * 2) - np.testing.assert_array_equal((real_cube - real_cube).array, - np.zeros_like(real_cube.array)) + np.testing.assert_array_equal( + (real_cube - real_cube).array, np.zeros_like(real_cube.array) + ) def test_real_cube_numpy_interop(real_cube): diff --git a/tests/test_general.py b/tests/test_general.py index 7e950d2..8e181d6 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -5,8 +5,9 @@ version information, and configuration. """ -import pytest import logging +from importlib.metadata import version as imp_version + import cuvis @@ -30,7 +31,7 @@ def test_wrapper_version(sdk_initialized): """Test wrapper version retrieval.""" version = cuvis.General.wrapper_version() assert isinstance(version, str) - assert "3.5.3" in version # Current wrapper version + assert version.startswith(imp_version("cuvis")) # def test_sdk_initialization_and_shutdown(): diff --git a/tests/test_measurement.py b/tests/test_measurement.py index 0a903e9..72ee40b 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -5,7 +5,6 @@ measurement data access, properties, and metadata. """ -import pytest import datetime import cuvis @@ -92,7 +91,10 @@ def test_unsupported_data_entries_do_not_overwrite_each_other(test_measurement): The C API reports entries it cannot hand out with an empty key. They all used to land under the same dictionary key and so collapsed into a single one. """ - unsupported = {key: value for key, value in test_measurement.data.items() - if isinstance(value, str) and value.startswith("Not Implemented!")} + unsupported = { + key: value + for key, value in test_measurement.data.items() + if isinstance(value, str) and value.startswith("Not Implemented!") + } assert len(unsupported) == len(set(unsupported)) assert "" not in test_measurement.data diff --git a/tests/test_processing_context.py b/tests/test_processing_context.py index b6ccf1e..eb1b886 100644 --- a/tests/test_processing_context.py +++ b/tests/test_processing_context.py @@ -5,7 +5,6 @@ processing modes, reference handling, and cube generation. """ -import pytest import cuvis diff --git a/tests/test_session_file.py b/tests/test_session_file.py index 1a5a45f..40b58ba 100644 --- a/tests/test_session_file.py +++ b/tests/test_session_file.py @@ -5,7 +5,6 @@ SessionFile loading, iteration, and metadata access. """ -import pytest import cuvis From 5356eb4155230d365525f0741db99f893573015f Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Wed, 19 Aug 2026 11:54:33 +0200 Subject: [PATCH 2/2] first version of changelog --- .github/workflows/ci.yml | 95 +++++++ .github/workflows/publish_version.yml | 146 ----------- .github/workflows/release.yml | 250 +++++++++++++++++++ .github/workflows/tests.yml | 17 -- .gitignore | 1 + CHANGELOG.md | 347 ++++++++++++++++++++++++++ CONTRIBUTING.md | 166 ++++++++++++ README.md | 14 +- 8 files changed, 872 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/publish_version.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e6653bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + pull_request: + branches: [develop, main] + push: + branches: [develop, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + meta: + name: Resolve versions + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + sdk: ${{ steps.meta.outputs.sdk }} + ruff: ${{ steps.meta.outputs.ruff }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Read pyproject.toml + id: meta + run: python scripts/release_meta.py >> "$GITHUB_OUTPUT" + + lint: + name: Lint + needs: meta + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install ruff + run: python -m pip install "${{ needs.meta.outputs.ruff }}" + + - name: Check formatting + run: ruff format --check --diff . + + - name: Check lint rules + run: ruff check --output-format github . + + changelog: + name: Changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Validate CHANGELOG.md structure + run: python scripts/check_changelog.py + + - name: Require a changelog entry + if: > + github.event_name == 'pull_request' + && !contains(github.event.pull_request.labels.*.name, 'no-changelog') + run: | + if ! git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -qx CHANGELOG.md; then + echo "This pull request does not touch CHANGELOG.md." + echo "Add an entry under '## [Unreleased]' following CONTRIBUTING.md," + echo "or label the pull request 'no-changelog' if the change is genuinely invisible to users." + exit 1 + fi + + tests: + name: Tests + needs: meta + runs-on: ubuntu-latest + container: + image: cubertgmbh/cuvis_pyil:${{ needs.meta.outputs.sdk }}-ubuntu24.04 + steps: + - uses: actions/checkout@v5 + + - name: Install the wrapper with test dependencies + run: python3 -m pip install -e ".[test]" + + - name: Run tests + run: python3 -m pytest diff --git a/.github/workflows/publish_version.yml b/.github/workflows/publish_version.yml deleted file mode 100644 index 68082f3..0000000 --- a/.github/workflows/publish_version.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Publish to PyPI - -on: - workflow_dispatch: - inputs: - repository: - description: "Target index" - type: choice - options: [pypi, testpypi] - default: testpypi - ref: - description: "Git ref to build (branch/tag/SHA). Leave empty to use the UI-selected ref." - required: false - default: "" - -concurrency: - group: pypi-publish - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - container: - image: cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04 - - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || github.ref }} - - # Optional hard stop: only allow repo admins to proceed - - name: Enforce admin-only trigger - uses: actions/github-script@v7 - with: - script: | - const owner = context.repo.owner; - const repo = context.repo.repo; - const username = context.actor; - - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, repo, username - }); - - core.info(`Actor permission: ${data.permission}`); - if (data.permission !== "admin") { - core.setFailed(`Only repository admins may publish. (${username} has: ${data.permission})`); - } - - - name: Install build tooling - run: python3 -m pip install -U build twine - - - name: Read package name/version from pyproject.toml - id: meta - run: | - python3 - <<'PY' - import sys, json - try: - import tomllib # py3.11+ - except ModuleNotFoundError: - import tomli as tomllib # fallback if needed - from pathlib import Path - - data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - proj = data.get("project", {}) - name = proj.get("name") - version = proj.get("version") - if not name or not version: - print("Missing [project].name or [project].version in pyproject.toml", file=sys.stderr) - sys.exit(2) - - print(f"name={name}") - print(f"version={version}") - with open("pkg_meta.json", "w", encoding="utf-8") as f: - json.dump({"name": name, "version": version}, f) - PY - echo "name=$(python3 -c "import json; print(json.load(open('pkg_meta.json'))['name'])")" >> "$GITHUB_OUTPUT" - echo "version=$(python3 -c "import json; print(json.load(open('pkg_meta.json'))['version'])")" >> "$GITHUB_OUTPUT" - - - name: Abort if this version already exists on the target index - env: - NAME: ${{ steps.meta.outputs.name }} - VERSION: ${{ steps.meta.outputs.version }} - TARGET: ${{ inputs.repository }} - run: | - python3 - <<'PY' - import json, os, sys, urllib.request, urllib.error - - name = os.environ["NAME"] - version = os.environ["VERSION"] - target = os.environ["TARGET"] - - base = "https://pypi.org/pypi" if target == "pypi" else "https://test.pypi.org/pypi" - url = f"{base}/{name}/json" - - try: - with urllib.request.urlopen(url) as resp: - data = json.load(resp) - except urllib.error.HTTPError as e: - if e.code == 404: - print(f"{name} not found on {target}; OK to publish {version}.") - sys.exit(0) - raise - - releases = data.get("releases", {}) - if version in releases and releases[version]: - print(f"Version {name}=={version} already exists on {target}. Aborting.") - sys.exit(1) - - print(f"Version {name}=={version} not present on {target}; OK to publish.") - PY - - - name: Build sdist and wheel - run: | - python3 -m build - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - publish: - needs: build - runs-on: ubuntu-latest - - environment: ${{ inputs.repository }} - - permissions: - contents: read - id-token: write # required for PyPI Trusted Publishing (OIDC) - - steps: - - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 - with: - repository-url: ${{ inputs.repository == 'pypi' && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..914beae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,250 @@ +name: Release + +# Pushing a vMAJOR.MINOR.PATCH.TWEAK tag on main releases that commit. +# workflow_dispatch runs the same validation, build and TestPyPI publish as a +# dry run, and stops before PyPI. +on: + push: + tags: ['v*.*.*.*'] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate: + name: Validate release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + sdk: ${{ steps.meta.outputs.sdk }} + ruff: ${{ steps.meta.outputs.ruff }} + is_tag: ${{ startsWith(github.ref, 'refs/tags/') }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Restrict manual dry runs to repository admins + if: github.event_name == 'workflow_dispatch' + uses: actions/github-script@v7 + with: + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor, + }); + if (data.permission !== "admin") { + core.setFailed(`Only repository admins may publish. (${context.actor} has: ${data.permission})`); + } + + - name: Read pyproject.toml + id: meta + run: python scripts/release_meta.py >> "$GITHUB_OUTPUT" + + - name: Verify the tag points at a commit on main + if: startsWith(github.ref, 'refs/tags/') + run: | + git fetch --no-tags origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "Tag ${GITHUB_REF_NAME} is not an ancestor of main." + echo "Releases are cut from main; merge the release pull request first." + exit 1 + fi + + - name: Verify tag, version and changelog agree + if: startsWith(github.ref, 'refs/tags/') + run: python scripts/check_changelog.py --tag "${GITHUB_REF_NAME}" + + - name: Validate CHANGELOG.md structure + if: github.event_name == 'workflow_dispatch' + run: python scripts/check_changelog.py + + - name: Verify the version is not published yet + if: startsWith(github.ref, 'refs/tags/') + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + python - <<'PY' + import json, os, sys, urllib.error, urllib.request + + version = os.environ["VERSION"] + try: + with urllib.request.urlopen("https://pypi.org/pypi/cuvis/json") as response: + released = json.load(response)["releases"] + except urllib.error.HTTPError as error: + if error.code != 404: + raise + released = {} + + if released.get(version): + sys.exit( + f"cuvis=={version} is already on PyPI and cannot be replaced. " + "Bump TWEAK, move the CHANGELOG.md section, and tag again." + ) + print(f"cuvis=={version} is not published yet.") + PY + + - name: Install ruff + run: python -m pip install "${{ steps.meta.outputs.ruff }}" + + - name: Check formatting and lint rules + run: | + ruff format --check . + ruff check . + + tests: + name: Tests + needs: validate + runs-on: ubuntu-latest + container: + image: cubertgmbh/cuvis_pyil:${{ needs.validate.outputs.sdk }}-ubuntu24.04 + steps: + - uses: actions/checkout@v5 + + - name: Install the wrapper with test dependencies + run: python3 -m pip install -e ".[test]" + + - name: Run tests + run: python3 -m pytest + + build: + name: Build distributions + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install build tooling + run: python -m pip install build twine + + - name: Record the commit hash in the package + run: python prebuild.py + + - name: Build sdist and wheel + run: python -m build + + - name: Validate metadata + run: twine check dist/* + + - name: Verify the commit hash shipped in the wheel + run: | + python - <<'PY' + import glob, subprocess, zipfile + wheel = glob.glob("dist/*.whl")[0] + shipped = zipfile.ZipFile(wheel).read("cuvis/git-hash.txt").decode().strip() + head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + assert shipped == head, f"{wheel} carries {shipped}, expected {head}" + print(f"{wheel} carries {shipped}") + PY + + - name: List contents + run: | + ls -lh dist/ + tar tzf dist/*.tar.gz + + - uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/ + + publish-testpypi: + name: Publish to TestPyPI + needs: [validate, tests, build] + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + + - uses: pypa/gh-action-pypi-publish@v1.13.0 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Confirm the version is downloadable from TestPyPI + run: | + for attempt in 1 2 3 4 5 6; do + if python -m pip download --no-deps --dest /tmp/smoke \ + --index-url https://test.pypi.org/simple/ \ + "cuvis==${{ needs.validate.outputs.version }}"; then + ls -lh /tmp/smoke + exit 0 + fi + echo "Not indexed yet, retrying in 30s (attempt ${attempt}/6)." + sleep 30 + done + echo "cuvis==${{ needs.validate.outputs.version }} never appeared on TestPyPI." + exit 1 + + publish-pypi: + name: Publish to PyPI + needs: [validate, publish-testpypi] + if: needs.validate.outputs.is_tag == 'true' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + + - uses: pypa/gh-action-pypi-publish@v1.13.0 + + github-release: + name: Create GitHub release + needs: [validate, publish-pypi] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + + - name: Extract the changelog section + id: notes + run: | + { + echo 'body<> "$GITHUB_OUTPUT" + + - uses: softprops/action-gh-release@v2 + with: + name: cuvis ${{ needs.validate.outputs.version }} + body: ${{ steps.notes.outputs.body }} + files: dist/* + draft: false + prerelease: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 5ee5942..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Tests - -on: - pull_request: - -jobs: - tests: - runs-on: ubuntu-latest - container: - image: cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install test dependencies - run: python3 -m pip install -e ".[test]" - - name: Run tests - run: pytest diff --git a/.gitignore b/.gitignore index d3a4025..43e869c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /cuvis/_cuvis_pyil.pyd /venv /cuvis/__pycache__ +/cuvis/git-hash.txt /tests/__pycache__ /.claude diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..17a36de --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,347 @@ +# Changelog + +All notable changes to the `cuvis` Python wrapper are documented here. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Entry wording follows the conventions in [CONTRIBUTING.md](CONTRIBUTING.md#changelog-conventions) - every API entry names the fully qualified symbol first, then states the change with one of the fixed predicates. + +Versions are `MAJOR.MINOR.PATCH.TWEAK`. +`MAJOR.MINOR.PATCH` is the cuvis SDK release the wrapper targets; `TWEAK` counts wrapper-only revisions against that same SDK. +See [CONTRIBUTING.md](CONTRIBUTING.md#version-scheme) for the full scheme. + +Entries for versions released before this file existed were reconstructed from the published PyPI artifacts and from an AST-level diff of the public `cuvis` API surface between the corresponding commits. +Pre-releases (`b*`, `rc*`) are not listed. + +## [Unreleased] + +### Added + +- `CI` - `.github/workflows/ci.yml` runs the test suite and a lint job enforcing `ruff check` and `ruff format --check` on every pull request and on every push to `develop` and `main`. +- `CI` - `.github/workflows/release.yml` is driven by `v*.*.*.*` tags: it validates the tag against `pyproject.toml` and against this file, builds, publishes to TestPyPI, and publishes to PyPI plus a GitHub Release after manual approval. +- `CI` - `scripts/check_changelog.py` validates this file's structure (header format, allowed section names, descending versions) and the tag/version/changelog agreement at release time. +- `CONTRIBUTING.md` - documents the branch model, the version scheme, the changelog conventions and the release checklist. +- `pyproject.toml` - `dev` extra pinning `ruff==0.16.3`, plus `[tool.ruff]` configuration selecting the `E4`, `E7`, `E9` and `F` rule sets. + +### Changed + +- Whole tree reformatted with `ruff format`; no behaviour change. +- `README.md` - documents the version scheme, and lists Python 3.14 among the supported interpreters as `pyproject.toml` already did. +- `prebuild.py` - writes `cuvis/git-hash.txt` instead of `git-hash.txt` at the repository root, so the file lands inside the package that declares it as package data. +- `cuvis.FileWriteSettings.GeneralExportSettings.__repr__`, `cuvis.FileWriteSettings.ViewerSettings.__repr__` - the docstring that sat below the nested helper, where it was a dead expression rather than a docstring, moved to the top of the method. + +### Removed + +- `CI` - `.github/workflows/tests.yml` and `.github/workflows/publish_version.yml` removed; their jobs moved into `ci.yml` and `release.yml`. +- `git-hash.txt` at the repository root - removed; the file is generated into `cuvis/` by `prebuild.py` and is no longer tracked. +- Unused imports and locals dropped from `cuvis.AcquisitionContext`, `cuvis.Export`, `cuvis.General`, `cuvis.Measurement`, `cuvis.Viewer`, `cuvis.Worker`, `cuvis.cuvis_aux`, `cuvis.doc` and the test modules; no public name was removed. + +### Fixed + +- `cuvis.General.wrapper_version` - raised `FileNotFoundError` on every pip-installed release since 3.3.1, because it read `git-hash.txt` from the directory above the package while no distribution ever shipped that file. + The hash is now read from `cuvis/git-hash.txt`, which the wheel and the sdist do ship, and a missing file degrades to reporting the version alone instead of raising. +- `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. + +## [3.5.3.2] - 2026-08-19 + +Targets cuvis SDK 3.5.3. +Wrapper-only revision. + +### Added + +- `cuvis.cube_utils.ImageData.spectrum` - new property returning the single spectrum of a point-spectrometer measurement as a 1-D `np.ndarray`. +- `cuvis.cube_utils.ImageData.is_spectrum` - new property, `True` when the buffer is a `1 x 1 x N` point spectrum rather than a cube. +- `cuvis.cube_utils.ImageData.shape` - new property returning the buffer shape as a tuple, or `None` when no buffer is attached. +- `cuvis.cube_utils.ImageData.dtype` - new property returning the numpy dtype of the underlying buffer. +- `cuvis.cube_utils.ImageData.__array__` - new method, so `np.asarray(image_data)` and any numpy call accepting an array-like now work directly. +- `cuvis.cube_utils.ImageData.__array_ufunc__` - new method, so numpy ufuncs applied to an `ImageData` return plain `np.ndarray` results. +- `cuvis.cube_utils.ImageData.__repr__` - new method reporting shape, dtype and spectrum-ness. + +### Changed + +- `cuvis.cube_utils.ImageData.from_array` - parameters `width`, `height` and `channels` gained the default `None`; they are inferred from the array shape when omitted. +- `cuvis.cube_utils.ImageData.__getitem__` - return type changed from `Union[np.ndarray, tuple[np.ndarray, np.ndarray], object]` to `Union[np.ndarray, tuple, ImageData, np.generic]`; band slices now come back wrapped as `ImageData` and scalar reads as numpy scalars. + +### Fixed + +- `cuvis.cube_utils.ImageData` - reading a qmini point spectrum failed because the `1 x 1 x N` buffer shape was not handled in the indexing path. +- `cuvis.cube_utils.ImageData` - comparing against a scalar (`cube > 500`) raised `TypeError`; comparisons now dispatch through the numpy protocol and return a boolean array. + +## [3.5.3.1] - 2026-06-02 + +Targets cuvis SDK 3.5.3. +Wrapper-only revision. + +### Added + +- `cuvis.FileWriteSettings.SaveArgs.allow_overwrite` - new field, default `False`, mapping to the SDK's `allow_overwrite` save flag. + +### Changed + +- `cuvis.FileWriteSettings.PanSharpeningSettings.spectra_multiplier` - type changed from `int` to `float`, matching the SDK field. +- `cuvis.cuvis_aux.SensorInfo.temperature` - type changed from `int` to `float`, matching the SDK field. + +### Removed + +- `cuvis.FileWriteSettings.SaveArgs.allow_fragmentation` - removed; the field never mapped to an SDK flag of that name. + Use `allow_overwrite`, or `SaveArgs.merge_mode` for fragmentation behaviour. + +### Fixed + +- `cuvis.FileWriteSettings.SaveArgs` - `pan_scale` was coerced with `float()` on the way into the SDK struct although the SDK expects the value unconverted; the redundant cast was removed. + +## [3.5.3.0] - 2026-06-01 + +Targets cuvis SDK 3.5.3. +SDK synchronisation release; the wrapper's public API is unchanged from 3.5.1.0. + +### Changed + +- `README.md` - the link to the cuvis C SDK was made more prominent. + +## [3.5.1.0] - 2026-02-25 + +Targets cuvis SDK 3.5.1. +SDK synchronisation release; the wrapper's public API is unchanged from 3.5.0.2. + +### Added + +- `CI` - `.github/workflows/publish_version.yml`, a manually dispatched PyPI/TestPyPI publish workflow restricted to repository admins. + +## [3.5.0.2] - 2026-01-12 + +Targets cuvis SDK 3.5.0. +Wrapper-only revision. + +### Added + +- `tests/` - first unit-test suite for the wrapper, covering acquisition, cube utilities, export, general, measurement, processing context, session file and worker. + +### Changed + +- `cuvis.FileWriteSettings.WorkerSettings.input_queue_size` - default changed from `0` to `10`; `0` disabled the input queue entirely, which is never what a caller constructing `WorkerSettings()` wants. + +### Fixed + +- `cuvis.FileWriteSettings.ViewerSettings.__post_init__` - constructing `ViewerSettings()` without arguments took the deprecation path for the pan-sharpening keywords, because a value equal to the class default was indistinguishable from an explicitly passed one. + Defaults are now compared against the class attribute and skipped. + +## [3.5.0.1] - 2025-12-27 + +Targets cuvis SDK 3.5.0. +Wrapper-only revision. + +### Fixed + +- `cuvis.FileWriteSettings.GeneralExportSettings`, `cuvis.FileWriteSettings.ViewerSettings` - `channel_selection`, `spectra_multiplier`, `pan_scale`, `pan_sharpening_interpolation_type`, `pan_sharpening_algorithm`, `pre_pan_sharpen_cube` and `add_pan` could no longer be passed to the constructor after 3.5.0 turned them into properties. + They are accepted again as deprecated `InitVar` keywords that forward to `pan_sharpening`. + +## [3.5.0] - 2025-12-23 + +Targets cuvis SDK 3.5.0. + +### Added + +- `cuvis.FileWriteSettings.PanSharpeningSettings` - new dataclass grouping every pan-sharpening option (`channel_selection`, `spectra_multiplier`, `pan_scale`, `pan_sharpening_interpolation_type`, `pan_sharpening_algorithm`, `pre_pan_sharpen_cube`, `add_pan`). +- `cuvis.FileWriteSettings.GeneralExportSettings.pan_sharpening`, `cuvis.FileWriteSettings.ViewerSettings.pan_sharpening` - new field holding a default-constructed `PanSharpeningSettings`. +- `cuvis.cuvis_types.SessionMergeMode` - new enum with members `Default = 0`, `Fragmentation = 1`, `Merge = 2`. +- `cuvis.FileWriteSettings.SaveArgs.merge_mode` - new field, default `SessionMergeMode.Default`. +- `cuvis.cuvis_types.PanSharpeningAlgorithm.PCAFusion` - new enum member with value `4`. +- `cuvis.AcquisitionContext.AcquisitionContext.dead_pixel_correction` - new property setter. +- `cuvis.AcquisitionContext.AcquisitionContext.dead_pixel_correction_available` - new read-only property. + +### Changed + +- `cuvis.FileWriteSettings.GeneralExportSettings.channel_selection`, `.spectra_multiplier`, `.pan_scale`, `.pan_sharpening_interpolation_type`, `.pan_sharpening_algorithm`, `.pre_pan_sharpen_cube`, `.add_pan` - fields became properties with setters that delegate to `pan_sharpening`. +- `cuvis.FileWriteSettings.ViewerSettings.channel_selection`, `.spectra_multiplier`, `.pan_scale`, `.pan_sharpening_interpolation_type`, `.pan_sharpening_algorithm`, `.pre_pan_sharpen_cube`, `.add_pan` - fields became properties with setters that delegate to `pan_sharpening`. +- `cuvis.ProcessingContext.ProcessingContext.__init__` - new parameter `load_references: bool = True`, so reference loading can be skipped explicitly. +- `cuvis.General.version` - version reporting switched from `pkg_resources` to `importlib.metadata.version`. +- `pyproject.toml` - `cuvis-il` requirement raised from `>3.3.1` to `>=3.5.0,<3.6.0`. + +### Removed + +- `cuvis.cuvis_types.PanSharpeningAlgorithm.AlphaBlendOverlay` - removed; the SDK dropped the algorithm. + Value `4` is now `PCAFusion`. +- `cuvis.FileWriteSettings.ViewerSettings.blend_opacity` - removed together with `AlphaBlendOverlay`. +- `cuvis.FileWriteSettings.SaveArgs.allow_overwrite` - removed in favour of `SaveArgs.merge_mode`. + Reinstated in 3.5.3.1. + +### Fixed + +- `cuvis` - dead-pixel-correction and pan-sharpening wrapper code wrote wrong values into the SDK structs. + +## [3.4.1.1] - 2026-03-12 + +Targets cuvis SDK 3.4.1. +Wrapper-only revision, released from `release/v3.4` after the 3.5 line had already opened. +The wrapper's public API is unchanged from 3.4.1. + +### Changed + +- `cuvis.General.version` - version reporting switched from `pkg_resources` to `importlib.metadata.version`, so the wrapper no longer depends on the removed `pkg_resources` API. + Backport of the same change made on `main` for 3.5.0. + +## [3.4.1] - 2025-10-01 + +Targets cuvis SDK 3.4.1. + +### Changed + +- `cuvis.Viewer.Viewer.apply` - return type changed from `dict[str, ImageData]` to `Union[dict[str, ImageData], ImageData]`; a single-view configuration returns the image directly. +- `pyproject.toml` - `cuvis-il` requirement raised from `>3.3.1` to `>=3.4.0,<3.5.0`. + +## [3.4.0.post1] - 2025-07-03 + +Targets cuvis SDK 3.4.0. +Packaging-only re-release. + +### Changed + +- `pyproject.toml` - `cuvis-il` requirement capped at `<3.5.0`, so a 3.4 wrapper install cannot pull a 3.5 interface layer. + +## [3.4.0] - 2025-07-02 + +Targets cuvis SDK 3.4.0. + +### Added + +- `cuvis.cuvis_aux.ComponentInfo` - new dataclass with fields `display_name`, `pixel_format`, `sensor_info`, `type` and `user_field`. + Replaces `cuvis.General.ComponentInfo`. +- `cuvis.cuvis_aux.CalibrationInfo.cube_width`, `.cube_height`, `.cube_channels`, `.cube_wavelengths` - new fields exposing the calibrated cube geometry. +- `cuvis.cuvis_aux.SensorInfo.integration_time` - new field of type `float`. +- `cuvis.FileWriteSettings.ViewExportSettings.pan_failback`, `cuvis.FileWriteSettings.ViewerSettings.pan_failback` - new field, default `True`. +- `cuvis.General.init` - new parameter `logfile_name: Optional[str] = None`, making the log file name configurable. +- The settings directory can be supplied through an environment variable instead of only through `cuvis.General.init(settings_path=...)`. + +### Changed + +- `cuvis.General.init` - parameter `global_loglevel` type changed from `int` to `Union[int, str]`, so level names are accepted. +- `cuvis.General.set_log_level` - parameter `lvl` type changed from unannotated to `Union[int, str]`. + +### Removed + +- `cuvis.General.init` - parameter `log_path` removed; use `logfile_name`. +- `cuvis.General.ComponentInfo` - removed; moved to `cuvis.cuvis_aux.ComponentInfo`. +- `cuvis.AcquisitionContext.AcquisitionContext.binning` - property setter removed; the SDK no longer exposes a binning toggle. +- `cuvis.AcquisitionContext.AcquisitionContext.set_binning_async` - removed together with the `binning` setter. +- `cuvis.cuvis_aux.SensorInfo.binning` - removed together with the `binning` setter. + +### Fixed + +- `cuvis` - type annotations that were invalid on Python 3.9 corrected, restoring the declared `requires-python = ">=3.9"` floor. +- `cuvis.Viewer.Viewer` - incorrect return annotation corrected. + +## [3.3.3] - 2025-05-19 + +Targets cuvis SDK 3.3.3. + +### Changed + +- `cuvis.Measurement.Measurement.name`, `.comment` - fields became properties with setters, replacing `set_name` and `set_comment`. +- `cuvis.Measurement.Measurement.assembly`, `.averages`, `.capture_time`, `.distance`, `.factory_calibration`, `.frame_id`, `.integration_time`, `.measurement_flags`, `.path`, `.processing_mode`, `.product_name`, `.serial_number`, `.session_info` - fields became read-only properties, so the values are read from the SDK on access instead of being snapshotted at construction. + +### Removed + +- `cuvis.Measurement.Measurement.set_name` - removed; assign to the `name` property. +- `cuvis.Measurement.Measurement.set_comment` - removed; assign to the `comment` property. + +## [3.3.2] - 2025-03-17 + +Targets cuvis SDK 3.3.2. +The wrapper's public API is unchanged from 3.3.1. + +### Changed + +- Build metadata moved from `setup.py` to `pyproject.toml`; the package is built with the setuptools PEP 517 backend. + +## [3.3.1] - 2025-03-05 + +Targets cuvis SDK 3.3.1. + +### Added + +- `cuvis.cube_utils.ImageData` - new class, moved out of `cuvis.Measurement`. +- `cuvis.cube_utils.ImageData.from_array` - new classmethod building an `ImageData` from an `np.ndarray` plus `width`, `height`, `channels` and optional `wavelength`. +- `cuvis.cube_utils.ImageData.to_numpy` - new method returning the buffer as an `np.ndarray`. +- `cuvis.Measurement.Measurement.cube` - new property returning the cube as `ImageData`. +- `cuvis.SessionFile.SessionFile.thumbnail` - new property returning the thumbnail as `ImageData`, replacing `get_thumbnail`. +- `cuvis.Calibration.Calibration.info` - new property returning a `CalibrationInfo`, replacing `get_info`. +- `cuvis.AcquisitionContext.AcquisitionContext.ready` - new read-only property. +- `cuvis.AcquisitionContext.AcquisitionContext.register_ready_callback` - new method taking `Callable[None, Awaitable[None]]`. +- `cuvis.AcquisitionContext.AcquisitionContext.reset_ready_callback` - new method clearing the registered callback. +- `cuvis.FileWriteSettings.ViewerSettings` - new dataclass for `Viewer` configuration, with `complete`, `blend_opacity`, `pan_scale`, `pan_sharpening_algorithm`, `pan_sharpening_interpolation_type`, `pre_pan_sharpen_cube` and a `userplugin` setter. +- `cuvis.General.sdk_version` - new function returning the loaded SDK version. +- `cuvis.General.wrapper_version` - new function returning the wrapper's own version, distinct from the SDK version. + +### Changed + +- `cuvis.Viewer.Viewer.__init__` - parameter `settings` type changed from `Union[int, ViewExportSettings]` to `Union[int, ViewerSettings]`. +- `cuvis.AcquisitionContext.AcquisitionContext.capture` - new parameter `to_interal = False`, and the return type changed from `AsyncMesu` to `Optional[AsyncMesu]`. +- `cuvis.Measurement.Measurement.__init__` - parameter `base` type changed from `Union[int, str]` to `Union[int, str, Path]`. +- `cuvis.SessionFile.SessionFile.get_measurement` - parameter `frameNo` gained the default `0`. +- `cuvis.Async.AsyncMesu.get`, `cuvis.Viewer.Viewer.apply`, `cuvis.cuvis_aux.Bitset.strings` - return annotations switched from `typing.Tuple`/`Dict`/`List` to the builtin generics. + +### Removed + +- `cuvis.Measurement.ImageData` - removed; moved to `cuvis.cube_utils.ImageData`. +- `cuvis.SessionFile.SessionFile.get_thumbnail` - removed; use the `thumbnail` property. +- `cuvis.Calibration.Calibration.get_info` - removed; use the `info` property, which returns `CalibrationInfo` instead of the raw `cuvis_calibration_info_t`. + +## [3.3.0.post1] - 2024-09-30 + +Targets cuvis SDK 3.3.0. +Packaging-only re-release; no source change is recorded in the repository for this version. + +## [3.3.0] - 2024-09-30 + +Targets cuvis SDK 3.3.0. + +### Added + +- `cuvis.General.init`, `cuvis.General.shutdown`, `cuvis.General.set_log_level`, `cuvis.General.version` - new module-level functions replacing the `General` class. +- `cuvis.cuvis_aux.WorkerState` - new dataclass with fields `framesInQueue`, `hasAcquisitionContext`, `isProcessing`, `measurementsBeingProcessed`, `measurementsInQueue`, `resultsInQueue` and `sessionFilesInQueue`. +- `cuvis.Worker.Worker.state` - new property returning a `WorkerState`. +- `cuvis.Worker.Worker.start_processing`, `.stop_processing`, `.drop_all_queued` - new methods giving explicit control over the processing loop. +- `cuvis.Worker.Worker.is_processing`, `.is_processing_mandatory`, `.threads_busy` - new read-only properties. +- `cuvis.Worker.Worker.can_drop_results`, `.can_skip_measurements`, `.can_skip_supplementary` - new read-only properties replacing the `drop_behaviour` setter. +- `cuvis.Worker.Worker.input_queue_limit`, `.mandatory_queue_limit`, `.output_queue_limit`, `.supplementary_queue_limit` - new read-only properties replacing the `queue_limits` setter. +- `cuvis.Worker.Worker.ingest_session_file` - new method taking `session: SessionFile` and `frame_selection: str = "all"`, replacing `set_session_file`. +- `cuvis.FileWriteSettings.WorkerSettings.input_queue_size`, `.mandatory_queue_size`, `.output_queue_size`, `.supplementary_queue_size` - new fields replacing `soft_limit` and `hard_limit`. +- `cuvis.FileWriteSettings.WorkerSettings.can_drop_results`, `.can_skip_measurements`, `.can_skip_supplementary_steps` - new fields replacing `can_drop` and `keep_out_of_sequence`. +- `cuvis.cuvis_aux.CalibrationInfo` - new dataclass with fields `annotation_name`, `calibration_date`, `file_path`, `model_name`, `serial_no` and `unique_id`. +- `cuvis.cuvis_aux.SensorInfo.width`, `.height`, `.pixel_format`, `.raw_frame_id`, `.binning` - new fields. +- `cuvis.Measurement.Measurement.averages`, `.distance`, `.frame_id` - new fields. +- `cuvis.Measurement.Measurement.thumbnail` - new property, replacing `get_thumbnail`. +- `cuvis.SessionFile.SessionFile.get_thumbnail` - new method. +- `cuvis.AcquisitionContext.AcquisitionContext.binning` - new property setter, replacing `preview_mode`. +- `cuvis.AcquisitionContext.AcquisitionContext.set_binning_async` - new method returning `Async`. +- `cuvis.AcquisitionContext.Component.pixel_format` - new property setter. +- `cuvis.AcquisitionContext.Component.available_pixel_formats` - new read-only property returning `list[str]`. +- `cuvis.Calibration.Calibration.get_info` - new method returning `cuvis_calibration_info_t`. +- `cuvis.Export.Exporter.flush` - new method. +- `cuvis.FileWriteSettings.SaveArgs.full_export` - new field, default `False`. +- `cuvis.FileWriteSettings.GeneralExportSettings.pre_pan_sharpen_cube` - new field, default `False`. +- `cuvis.doc.copydoc` - new decorator copying a docstring from another callable. + +### Changed + +- `cuvis.AcquisitionContext.Component.gain`, `.integration_time_factor` - type changed from `int` to `float`, and both gained setters. +- `cuvis.AcquisitionContext.Component.temperature` - return type changed from `int` to `float`. +- `cuvis.Worker.Worker.query_session_progress` - changed from a method to a read-only property returning `float`. +- `cuvis.FileWriteSettings.GeneralExportSettings.spectra_multiplier` - type changed from `float` to `int`, and the default from `1.0` to `1`. + +### Removed + +- `cuvis.General.General` - removed; replaced by module-level `init`, `shutdown`, `set_log_level` and `version`. +- `cuvis.AcquisitionContext.AcquisitionContext.preview_mode` - property setter removed; use `binning`. +- `cuvis.AcquisitionContext.AcquisitionContext.set_preview_mode_async` - removed; use `set_binning_async`. +- `cuvis.Worker.Worker.set_session_file` - removed; use `ingest_session_file`. +- `cuvis.Worker.Worker.drop_behaviour` - property setter removed; configure through `WorkerSettings` and read the `can_*` properties. +- `cuvis.Worker.Worker.queue_limits` - property setter removed; configure through `WorkerSettings` and read the `*_queue_limit` properties. +- `cuvis.Measurement.Measurement.get_thumbnail` - removed; use the `thumbnail` property. +- `cuvis.Measurement.Measurement.get_data_info` - removed. +- `cuvis.FileWriteSettings.WorkerSettings.soft_limit`, `.hard_limit`, `.poll_intervall`, `.worker_count`, `.can_drop`, `.keep_out_of_sequence` - removed; replaced by the `*_queue_size` and `can_*` fields. + +## [3.2.1] - 2023-12-01 + +Targets cuvis SDK 3.2.1. +First release covered by this changelog; the wrapper's history before this point is not reconstructed here. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1bd1895 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,166 @@ +# Contributing to cuvis.python + +This document covers the branch model, the version scheme, the changelog conventions and the release process. +For bug reports and questions use [GitHub Issues](https://github.com/cubert-hyperspectral/cuvis.python/issues). + +## Branch model + +| Branch | Role | +| --- | --- | +| `main` | The latest released wrapper state for the latest released cuvis SDK. Every commit on `main` is a release and carries a `v*` tag. Never receives direct pushes. | +| `develop` | Integration branch for the next release. All feature work lands here. | +| `feature/*` | One branch per change, cut from `develop`, merged back into `develop` by pull request. | +| `hotfix/*` | Cut from `main` when a released version needs a fix before `develop` is ready to release. Merged into `main` by pull request, tagged, then merged back into `develop`. | +| `release/vX.Y` | Maintenance branch for an older SDK line that still receives wrapper revisions. Cut from the corresponding tag on demand. | + +``` +feature/* -> develop -> main (tag vX.Y.Z.W) +hotfix/* -> main (tag vX.Y.Z.W) -> develop +``` + +A pull request into `develop` or `main` must pass the `ci.yml` lint and test jobs. + +## Version scheme + +Versions are `MAJOR.MINOR.PATCH.TWEAK`, always with all four components. + +- `MAJOR.MINOR.PATCH` is the cuvis SDK release this wrapper targets. + It is not chosen by the wrapper; it follows the SDK. +- `TWEAK` counts wrapper-only revisions against that same SDK release, starting at `0`. + +Examples: + +| Version | Meaning | +| --- | --- | +| `3.5.3.0` | First wrapper release for cuvis SDK 3.5.3. | +| `3.5.3.1` | Wrapper fix on top of it; the SDK is still 3.5.3. | +| `3.5.4.0` | First wrapper release for cuvis SDK 3.5.4. | + +Two consequences worth knowing: + +- PEP 440 treats `3.5.3.0` and `3.5.3` as the same version, so only one of the two forms may ever be published for a given release. + Tags created before this scheme was written down use the three-component form (`v3.5.3` is release `3.5.3.0`); everything from `v3.5.3.2` onward is four-component. +- A `TWEAK` bump never widens or narrows the `cuvis-il` requirement in `pyproject.toml`. + If the interface layer requirement changes, the SDK it targets changed, so the change belongs in a `MAJOR.MINOR.PATCH` release. + +The version lives in exactly one place: `[project].version` in `pyproject.toml`. +The git tag is `v` followed by that value, and the release workflow refuses to publish when the two disagree. + +## Development setup + +```bash +python -m pip install -e ".[test,dev]" +``` + +The wrapper needs the cuvis SDK and the matching `cuvis-il` interface layer installed on the machine. +The container image `cubertgmbh/cuvis_pyil:-ubuntu24.04` ships both and is what CI uses. + +Run the checks the way CI runs them: + +```bash +ruff format --check . +ruff check . +pytest +``` + +`ruff format` is authoritative for formatting; do not hand-format around it. +The lint rule set is configured in `[tool.ruff.lint]` in `pyproject.toml` and is deliberately narrow. +Widening it is a separate, self-contained pull request, never a side effect of a feature. + +## Changelog conventions + +Every user-visible change is recorded in `CHANGELOG.md` under `## [Unreleased]` in the same pull request that makes the change. +The file follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and is validated by `scripts/check_changelog.py`, which CI runs on every pull request. + +### Structure + +A release section is a version in brackets, a release date, an SDK statement, and then the change sections: + +```markdown +## [3.5.3.2] - 2026-08-19 + +Targets cuvis SDK 3.5.3. +Wrapper-only revision. + +### Fixed + +- `cuvis.cube_utils.ImageData` - reading a qmini point spectrum failed because the `1 x 1 x N` buffer shape was not handled in the indexing path. +``` + +Rules the validator enforces: + +- Release headers are `## [] - `, plus one optional `## [Unreleased]` at the top. +- Versions descend down the file, and no version appears twice. +- Section headings are `### ` followed by exactly one of `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`, in that order, and each appears at most once per release. +- Every line inside a section is a `- ` bullet or an indented continuation line. + +### Entry wording + +Each bullet names what changed first, then states the change with one of the predicates below. +The subject is the fully qualified dotted path in backticks (`cuvis.Module.Class.member`), or a non-API scope in backticks (`pyproject.toml`, `CI`, `tests/`, `README.md`). +One sentence per bullet; a second sentence goes on its own indented continuation line. + +| Kind of change | Required form | +| --- | --- | +| New symbol | `` `path` - new [, ]. `` | +| New parameter | `` `path` - new parameter `name: Type = default`. `` | +| Parameter default added | `` `path` - parameter `name` gained the default `X`. `` | +| Type change | `` `path` - type changed from `A` to `B`. `` | +| Default change | `` `path` - default changed from `A` to `B`. `` | +| Return type change | `` `path` - return type changed from `A` to `B`. `` | +| Field becomes property | `` `path` - field became a . `` | +| Rename | `` `path` - renamed to `newpath`. `` | +| Removal | `` `path` - removed; . `` | +| Deprecation | `` `path` - deprecated; , removal planned for . `` | +| Behaviour fix | `` `path` - . `` | +| Dependency change | `` `pyproject.toml` - `` requirement raised from `A` to `B`. `` | + +Which section a change belongs in follows from the predicate: new symbols and parameters go under `Added`, type/default/signature changes under `Changed`, removals under `Removed`, behaviour corrections under `Fixed`. +A change that is both (a field that became a property, dropping the old setter) is listed once, under the section describing what callers must react to. + +Do not write commit subjects, pull request numbers or author names into the changelog. +The git history already records those, and they say nothing about the API. + +## Releasing + +### One-time repository setup + +The release workflow depends on settings that live outside the repository: + +- **Trusted publishers.** PyPI and TestPyPI bind a trusted publisher to a specific workflow file name. + The publisher for `cuvis` must name `release.yml`; it previously named `publish_version.yml`, so it has to be + updated once on both indexes or the publish step fails with an OIDC error. +- **Environments.** `testpypi` and `pypi` must exist under Settings -> Environments. + `pypi` carries the required reviewers that make step 7 below a human gate; without them the release + publishes unattended. +- **Branch protection.** `main` and `develop` require the `Lint`, `Changelog` and `Tests` checks from + `ci.yml`, and `main` additionally forbids direct pushes. + +### Regular release from `develop` + +1. On `develop`, confirm which SDK version the wrapper targets and that `cuvis-il` in `pyproject.toml` matches it. +2. Rename `## [Unreleased]` to `## [X.Y.Z.W] - ` and add the SDK statement lines beneath it. + Add a fresh empty `## [Unreleased]` above it. +3. Set `[project].version` in `pyproject.toml` to `X.Y.Z.W`. +4. Run `ruff format --check . && ruff check . && pytest && python scripts/check_changelog.py`. +5. Open a pull request `develop` -> `main` titled `release: vX.Y.Z.W` and merge it once CI is green. +6. Tag the merge commit on `main` and push the tag: + + ```bash + git checkout main && git pull + git tag -a vX.Y.Z.W -m "cuvis X.Y.Z.W" + git push origin vX.Y.Z.W + ``` + +7. `release.yml` validates the tag, builds, publishes to TestPyPI, and then waits for approval on the `pypi` environment before publishing to PyPI and creating the GitHub Release. +8. Merge `main` back into `develop` so the release commit is an ancestor of both. + +### Hotfix release from `main` + +Same as above, except the branch is `hotfix/` cut from `main`, the pull request targets `main` directly, only `TWEAK` increases, and step 8 becomes mandatory rather than tidy-up. + +### If a release goes wrong + +A published PyPI version cannot be replaced. +Fix forward with the next `TWEAK`; yank on PyPI only when the artifact is actively harmful. +Delete the tag and re-tag only while the release workflow has not yet published anything. diff --git a/README.md b/README.md index ea9ad15..1cd90e6 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pip install cuvis ``` or add `cuvis` to your project `requirements.txt` or `setup.py`. -We currently provide pre-compiled binaries for Python 3.9, 3.10, 3.11, 3.12 and 3.13 for Windows, Ubuntu 20.04 and Ubuntu 22.04 (all 64-bit). +We currently provide pre-compiled binaries for Python 3.9, 3.10, 3.11, 3.12, 3.13 and 3.14 for Windows, Ubuntu 20.04 and Ubuntu 22.04 (all 64-bit). To access the newest python cuvis version (for use with SDK beta versions, etc) you can install it with @@ -64,6 +64,16 @@ If you wish to use the version compatible to the available Cuvis SDK download, c For building the python bindings refer to [cuvis.pyil](https://github.com/cubert-hyperspectral/cuvis.pyil). +## Versioning + +Wrapper versions are `MAJOR.MINOR.PATCH.TWEAK`. +`MAJOR.MINOR.PATCH` is the Cuvis C SDK release this wrapper targets, and `TWEAK` counts wrapper-only +revisions against that same SDK; `3.5.3.1` is a wrapper fix on top of `3.5.3.0`, both for SDK 3.5.3. + +`main` always points at the latest released wrapper state, `develop` at the next release. +Every release is listed in [CHANGELOG.md](CHANGELOG.md). +The scheme and the release process are documented in [CONTRIBUTING.md](CONTRIBUTING.md). + ## How to ... ### Getting started @@ -83,6 +93,8 @@ source application development by a diverse group of contributors. Cubert GmbH aims for creating an open, inclusive, and positive community. Feel free to branch/fork this repository for later merge requests, open issues or point us to your application specific projects. +Before opening a pull request, please read [CONTRIBUTING.md](CONTRIBUTING.md); it covers the branch +model, the version scheme, the changelog conventions and the checks CI runs. Contact us, if you want your open source project to be included and shared on this hub; either if you search for direct support, collaborators or any other input or simply want your project being used by this community.