Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,26 @@ Pre-releases (`b*`, `rc*`) are not listed.
- `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.SdkSettings` - new class, a `MutableMapping` of setting id to value that writes the SDK's `cuvis.settings` file, so the SDK configuration can be built in Python instead of maintained by hand.
Values are stored as strings: `bool` becomes `true`/`false`, an `Enum` becomes its value, anything else goes through `str()`, and `None` drops the entry.
- `cuvis.SdkSettings.__enter__`, `cuvis.SdkSettings.__exit__` - new methods; entering the context serializes the settings into a temporary directory and returns its path as `str`, leaving the context removes the directory.
- `cuvis.SdkSettings.save` - new method, writes the settings to a file, or into a directory as `cuvis.settings`.
- `cuvis.SdkSettings.xml_str` - new read-only property, returns the serialized settings document as `str`.
- `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

- 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.General.init` - parameter `settings_path` type changed from `str` to `Union[str, Path, SdkSettings]`.
An `SdkSettings` is written to a temporary directory that exists only for the duration of the call, since the SDK reads the settings once during initialisation.
- `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
Expand Down
36 changes: 23 additions & 13 deletions cuvis/General.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import logging
import os
from contextlib import ExitStack
from importlib.metadata import version as imp_version

from ._cuvis_il import cuvis_il
from .cuvis_aux import SDKException
from .sdk_settings import SdkSettings
from pathlib import Path

import cuvis.cuvis_types as internal
Expand All @@ -12,22 +14,30 @@


def init(
settings_path: str = ".",
settings_path: Union[str, Path, SdkSettings] = ".",
global_loglevel: Union[int, str] = logging.DEBUG,
logfile_name: Optional[str] = None,
):
if "CUVIS_SETTINGS" in os.environ and settings_path == ".":
# env variable is set and settings path is default kwarg
settings_path = os.environ["CUVIS_SETTINGS"]

if isinstance(global_loglevel, str):
# also support string as input argument
global_loglevel = internal.__strToLogLevel__[global_loglevel]

if cuvis_il.status_ok != cuvis_il.cuvis_init(
settings_path, internal.__CuvisLoglevel__[global_loglevel], logfile_name
):
raise SDKException()
with ExitStack() as stack:
if isinstance(settings_path, SdkSettings):
# The SDK reads the settings once here, so the temporary directory
# only has to exist for the duration of the cuvis_init call.
settings_path = stack.enter_context(settings_path)
elif isinstance(settings_path, Path):
settings_path = str(settings_path)

if "CUVIS_SETTINGS" in os.environ and settings_path == ".":
# env variable is set and settings path is default kwarg
settings_path = os.environ["CUVIS_SETTINGS"]

if isinstance(global_loglevel, str):
# also support string as input argument
global_loglevel = internal.__strToLogLevel__[global_loglevel]

if cuvis_il.status_ok != cuvis_il.cuvis_init(
settings_path, internal.__CuvisLoglevel__[global_loglevel], logfile_name
):
raise SDKException()


def shutdown():
Expand Down
1 change: 1 addition & 0 deletions cuvis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from .ProcessingContext import ProcessingContext
from .Measurement import Measurement
from .General import init, shutdown, version, set_log_level
from .sdk_settings import SdkSettings
from .FileWriteSettings import (
GeneralExportSettings,
SaveArgs,
Expand Down
215 changes: 215 additions & 0 deletions cuvis/sdk_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import io
import tempfile
from collections.abc import Iterator, MutableMapping
from enum import Enum
from pathlib import Path
from typing import Any, Optional, Union
from xml.etree import ElementTree as ET

SETTINGS_NAMESPACE = "http://cubert-gmbh.de/core/settings.xsd"
SETTINGS_VERSION = "1.0.0"
SETTINGS_FILENAME = "cuvis.settings"

_SETTINGS_TAG = "{{{}}}settings".format(SETTINGS_NAMESPACE)
_PROPERTY_TAG = "{{{}}}property".format(SETTINGS_NAMESPACE)


def _resolve_source(source: Union[str, Path]) -> Path:
source = Path(source)
if not source.is_dir():
return source
settings_path = source / SETTINGS_FILENAME
if not settings_path.is_file():
raise FileNotFoundError(
"No '{}' found in directory '{}'.".format(SETTINGS_FILENAME, source)
)
return settings_path


def _load(path: Path) -> dict[str, str]:
try:
root = ET.parse(str(path)).getroot()
except ET.ParseError as e:
raise ValueError("Settings file '{}' is not valid XML: {}".format(path, e))

if root.tag != _SETTINGS_TAG:
raise ValueError(
"Settings file '{}' has root element '{}', expected '{}'.".format(
path, root.tag, _SETTINGS_TAG
)
)
if root.get("version") is None:
raise ValueError(
"Settings file '{}' is missing the required 'version' attribute.".format(
path
)
)

data = {}
for prop in root:
if prop.tag != _PROPERTY_TAG:
raise ValueError(
"Settings file '{}' contains unexpected element '{}', "
"expected '{}'.".format(path, prop.tag, _PROPERTY_TAG)
)
key = prop.get("id")
if key is None:
raise ValueError(
"Settings file '{}' contains a property without an 'id' "
"attribute.".format(path)
)
if key in data:
raise ValueError(
"Settings file '{}' contains duplicate property id '{}'.".format(
path, key
)
)
data[key] = prop.get("value", "")
return data


class SdkSettings(MutableMapping):
"""
Dict-like builder for the SDK's ``cuvis.settings`` file.

The SDK reads its configuration from a ``cuvis.settings`` file in the
directory passed to :func:`cuvis.init`. This class lets that configuration
be written in Python instead of maintaining the file by hand::

import cuvis

settings = cuvis.SdkSettings(force_gpu_mode="cuda", processing_thread_count=8)
cuvis.init(settings)

Settings are a flat mapping of setting id to value. Values are stored as
strings, because that is what the XML carries and what the SDK parses.
Booleans become ``"true"`` / ``"false"``, enums are written as their value,
and anything else goes through ``str()``. A value of ``None`` means "not
set" and is dropped rather than written.

Inspecting and editing
----------------------
The full ``MutableMapping`` interface is available, so an instance can be
read and edited like a ``dict``::

settings["verbose"] = True
del settings["force_gpu_mode"]
print(dict(settings)) # {'processing_thread_count': '8', 'verbose': 'true'}
print(settings.xml_str) # the exact file that will be written

Loading
-------
An existing configuration can be loaded from a settings file, or from the
directory containing one. Keyword arguments override what was loaded::

settings = cuvis.SdkSettings("/path/to/settings_dir", processing_thread_count=4)

Writing
-------
Used as a context manager, the settings are serialized into a temporary
directory that exists for the duration of the block. This is useful for
pointing other consumers at the same configuration::

with settings as settings_dir:
cuvis.init(settings_dir)

Use :meth:`save` to write the file to a permanent location instead.

Parameters
----------
source : str or Path, optional
A ``cuvis.settings`` file, or a directory containing one, to use as the
starting point. Positional only.
**kwargs
Settings to set, overriding any value read from ``source``.

Raises
------
FileNotFoundError
If ``source`` is a directory that contains no ``cuvis.settings``.
ValueError
If ``source`` is not a well-formed settings document, or if a setting
id is empty or contains whitespace.
"""

def __init__(self, source: Optional[Union[str, Path]] = None, /, **kwargs):
self._tmpdir = None
ET.register_namespace("", SETTINGS_NAMESPACE)

loaded = _load(_resolve_source(source)) if source is not None else {}
overrides = {
self._check_key(k): self._coerce_value(v)
for k, v in kwargs.items()
if v is not None
}
self._data = dict(loaded, **overrides)
self._rebuild_tree()

@staticmethod
def _check_key(key: str) -> str:
if not isinstance(key, str) or not key or key.split() != [key]:
raise ValueError(
"Invalid setting id {!r}: must be a non-empty string without "
"whitespace.".format(key)
)
return key

@staticmethod
def _coerce_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, Enum):
return str(value.value)
return str(value)

def __getitem__(self, key: str) -> str:
return self._data[key]

def __setitem__(self, key: str, value: Any) -> None:
self._data[self._check_key(key)] = self._coerce_value(value)
self._rebuild_tree()

def __delitem__(self, key: str) -> None:
del self._data[key]
self._rebuild_tree()

def __iter__(self) -> Iterator[str]:
return iter(self._data)

def __len__(self) -> int:
return len(self._data)

def __repr__(self) -> str:
return "{}({!r})".format(type(self).__name__, self._data)

def _rebuild_tree(self) -> None:
root = ET.Element(_SETTINGS_TAG)
root.set("version", SETTINGS_VERSION)
for key, value in self._data.items():
prop = ET.SubElement(root, _PROPERTY_TAG)
prop.set("id", key)
prop.set("value", value)
self._tree = ET.ElementTree(root)
ET.indent(self._tree)

@property
def xml_str(self) -> str:
buf = io.BytesIO()
self._tree.write(buf, xml_declaration=True, encoding="utf-8")
return buf.getvalue().decode("utf-8")

def save(self, path: Union[str, Path]) -> None:
path = Path(path)
if path.is_dir():
path = path / SETTINGS_FILENAME
self._tree.write(str(path), xml_declaration=True, encoding="utf-8")

def __enter__(self) -> str:
self._tmpdir = tempfile.TemporaryDirectory()
self.save(self._tmpdir.name)
return self._tmpdir.name

def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
self._tmpdir.cleanup()
self._tmpdir = None
return False
Loading