diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a36de..36ec078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ Pre-releases (`b*`, `rc*`) are not listed. - `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. +- `cuvis.BindingInfo` - new frozen dataclass with the fields `built_against: str`, `library_version: str`, `library_path: str` and `missing_symbols: Tuple[str, ...]`, the read-only property `is_complete: bool`, and a `__str__` rendering a report fit for a bug report. +- `cuvis.UnavailableSDKFunction` - new exception deriving from both `cuvis.cuvis_aux.SDKException` and `RuntimeError`, with the field `names: Tuple[str, ...]`. +- `cuvis.binding` - new module reporting the compiled binding, the cuvis library loaded beside it, and the functions that library does not provide. + Nothing in it needs the SDK to be initialised, so it can be called before `cuvis.init`. +- `cuvis.binding.available` - new function, returns `bool`. +- `cuvis.binding.info` - new function, returns `BindingInfo`. +- `cuvis.binding.missing_symbols` - new function, returns `FrozenSet[str]`. +- `cuvis.binding.require` - new function, raises `UnavailableSDKFunction` naming whichever of the given functions the installed cuvis library does not provide. - `pyproject.toml` - `dev` extra pinning `ruff==0.16.3`, plus `[tool.ruff]` configuration selecting the `E4`, `E7`, `E9` and `F` rule sets. ### Changed diff --git a/cuvis/__init__.py b/cuvis/__init__.py index 9bcb06c..0bcaa2c 100644 --- a/cuvis/__init__.py +++ b/cuvis/__init__.py @@ -35,6 +35,8 @@ WorkerSettings, ViewerSettings, ) +from . import binding +from .binding import BindingInfo, UnavailableSDKFunction from .Export import CubeExporter, EnviExporter, TiffExporter, ViewExporter from .Calibration import Calibration from .AcquisitionContext import AcquisitionContext diff --git a/cuvis/binding.py b/cuvis/binding.py new file mode 100644 index 0000000..8a89a1c --- /dev/null +++ b/cuvis/binding.py @@ -0,0 +1,208 @@ +"""Inspect the compiled binding and the cuvis library it is running against. + +The Python binding (``cuvis_il``) is compiled against one version of the cuvis SDK, +but the SDK itself is installed separately on the machine. The two can therefore +disagree: an installed library older than the binding may not export every function +the binding imports. The binding tolerates that rather than failing to import, and +records what it found; this module is how that information is read back. + +Nothing here needs the SDK to be initialised, so it can be called before +:func:`cuvis.init` to decide whether an operation is worth attempting at all. + +.. code-block:: python3 + + from cuvis import binding + + print(binding.info()) # human readable, fit for a bug report + + if not binding.info().is_complete: + ... # this SDK is missing something + + binding.require("cuvis_measurement_get_data_image_cuda") # or raise + +Which error you get depends on how the unavailable function is reached: + +* calling one through ``cuvis_il`` directly, or through a wrapper such as + :meth:`cuvis.Measurement.get_cube_cuda` that calls into it, raises + :class:`RuntimeError` from the binding layer, naming the function; +* calling :func:`require` first raises :class:`UnavailableSDKFunction`, which also + derives from :class:`RuntimeError`, so a single ``except RuntimeError`` covers + both, while ``except SDKException`` still catches it as an ordinary cuvis error. + +Against a binding too old to report any of this (an older ``cuvis_il`` wheel), every +query answers empty: :func:`missing_symbols` is empty, :func:`available` is ``True`` +and :func:`require` never raises. Absence of evidence, not evidence of absence. +""" + +from dataclasses import dataclass, field +from typing import FrozenSet, Tuple + +from ._cuvis_il import cuvis_il +from .cuvis_aux import SDKException + + +class UnavailableSDKFunction(SDKException, RuntimeError): + """The installed cuvis library does not provide a function that was required. + + Raised by :func:`require`. It derives from both :class:`SDKException` and + :class:`RuntimeError` on purpose: the binding layer raises a plain + :class:`RuntimeError` when an unavailable function is called directly, so + deriving from it lets one ``except RuntimeError`` handle either route, without + giving up ``except SDKException`` for code that treats all cuvis errors alike. + + :ivar names: the functions that were required but are not provided, in the order + they were requested. + """ + + def __init__(self, *names: str): + self.names = tuple(names) + current = info() + message = ( + "the installed CUVIS SDK ({}) does not provide {}; this binding was built " + "against {}".format( + current.library_version or "unknown version", + ", ".join(self.names) or "a required function", + current.built_against or "an unknown version", + ) + ) + # Deliberately not SDKException.__init__: that reads the SDK's last-error + # string, and here the library was never reached to set one. + Exception.__init__(self, message) + self.message = message + + +@dataclass(frozen=True) +class BindingInfo: + """A snapshot of the binding and the cuvis library loaded alongside it. + + Obtained from :func:`info`; printing it yields a short report suitable for + pasting into a bug report. + + :ivar built_against: version banner of the cuvis SDK the binding was compiled + against, for example ``"CUBERT SDK v. 3.5.3 build: 0f416fb..."``. Reported in + the same form as :attr:`library_version` so the two can be read side by side: + the build hash is what tells apart two libraries that report the same version. + Empty if the binding predates this feature. + :ivar library_version: the same banner, from the library that was actually loaded. + Empty if it could not be read. + :ivar library_path: file the binding loaded, for example + ``"/lib/cuvis/libcuvis.so"``. Useful when several copies are installed. + :ivar missing_symbols: names of functions the binding imports that the loaded + library does not export. Empty when the two agree. + """ + + built_against: str + library_version: str + library_path: str + missing_symbols: Tuple[str, ...] = field(default_factory=tuple) + + @property + def is_complete(self) -> bool: + """Whether the loaded library provides everything the binding imports. + + ``True`` also when the binding is too old to report missing functions, since + an empty list is all it can offer. + """ + return not self.missing_symbols + + def __str__(self) -> str: + """Render the snapshot as a short multi-line report. + + A binding that predates this feature is reported as unknown rather than as + complete, since an empty list of missing functions is all it can offer and + that is not the same as having checked. + """ + lines = [ + "cuvis binding", + " built against : {}".format(self.built_against or "unknown"), + " loaded library: {}".format(self.library_version or "unknown"), + " library path : {}".format(self.library_path or "unknown"), + ] + if not self.built_against: + lines.append(" status : unknown, this binding does not report it") + elif self.is_complete: + lines.append(" status : complete") + else: + lines.append( + " status : {} function(s) not provided by this SDK".format( + len(self.missing_symbols) + ) + ) + lines.extend( + " {}".format(name) for name in self.missing_symbols + ) + return "\n".join(lines) + + +def info() -> BindingInfo: + """Report the binding, the library it loaded and any functions it lacks. + + Cheap: the binding works all of this out once while being imported, so this only + reads the result. + + :return: a :class:`BindingInfo` snapshot. Fields the binding cannot supply, which + is everything when it predates this feature, come back empty rather than + raising. + """ + return BindingInfo( + built_against=getattr(cuvis_il, "built_against_version", ""), + library_version=getattr(cuvis_il, "library_version", ""), + library_path=getattr(cuvis_il, "library_path", ""), + missing_symbols=tuple(getattr(cuvis_il, "missing_symbols", ())), + ) + + +def missing_symbols() -> FrozenSet[str]: + """Functions the binding imports that the installed cuvis library does not export. + + :return: the set of C function names, empty when the SDK matches the binding and + also when the binding is too old to report them. + """ + return frozenset(getattr(cuvis_il, "missing_symbols", ())) + + +def available(*names: str) -> bool: + """Whether every named function is provided by the installed cuvis library. + + .. code-block:: python3 + + if binding.available("cuvis_measurement_get_data_image_cuda"): + cube = mesu.get_cube_cuda() + + :param names: C function names as they appear in ``cuvis.h``. + :return: ``True`` if none of them is reported missing. With a binding too old to + report anything this is always ``True``, so treat it as "nothing known to be + missing" rather than a guarantee. + """ + absent = missing_symbols() + return not any(name in absent for name in names) + + +def require(*names: str) -> None: + """Raise unless every named function is provided by the installed cuvis library. + + Use it at the start of an operation to fail with a clear explanation, instead of + letting a call fail deeper in with less context. + + .. code-block:: python3 + + binding.require("cuvis_cuda_mem_get_view", "cuvis_cuda_mem_free") + + :param names: C function names as they appear in ``cuvis.h``. + :raises UnavailableSDKFunction: naming whichever of them are missing; the message + also states the loaded SDK version and the one the binding expects. + """ + absent = missing_symbols() + unavailable = tuple(name for name in names if name in absent) + if unavailable: + raise UnavailableSDKFunction(*unavailable) + + +__all__ = [ + "BindingInfo", + "UnavailableSDKFunction", + "info", + "missing_symbols", + "available", + "require", +]