diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36ec078..ed0390f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,11 @@ 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`.
@@ -27,13 +32,14 @@ Pre-releases (`b*`, `rc*`) are not listed.
- `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
diff --git a/cuvis/General.py b/cuvis/General.py
index b499a4d..983250d 100644
--- a/cuvis/General.py
+++ b/cuvis/General.py
@@ -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
@@ -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():
diff --git a/cuvis/__init__.py b/cuvis/__init__.py
index 0bcaa2c..065de6c 100644
--- a/cuvis/__init__.py
+++ b/cuvis/__init__.py
@@ -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,
diff --git a/cuvis/sdk_settings.py b/cuvis/sdk_settings.py
new file mode 100644
index 0000000..a02ba98
--- /dev/null
+++ b/cuvis/sdk_settings.py
@@ -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
diff --git a/tests/test_sdk_settings.py b/tests/test_sdk_settings.py
new file mode 100644
index 0000000..706471f
--- /dev/null
+++ b/tests/test_sdk_settings.py
@@ -0,0 +1,222 @@
+"""
+Tests for the SdkSettings utility.
+
+Covers building settings in code, inspecting them like a dict, loading them
+back from a file or directory, and serializing them to a temporary directory.
+"""
+
+import os
+from pathlib import Path
+
+import pytest
+
+import cuvis
+from cuvis.sdk_settings import SETTINGS_FILENAME, SETTINGS_NAMESPACE, SdkSettings
+
+
+def _write(path, body):
+ """Write a raw settings XML document and return its path."""
+ path.write_text(body, encoding="utf-8")
+ return path
+
+
+VALID_XML = (
+ '\n'
+ '\n'
+ ' \n'
+ ' \n'
+ "\n".format(SETTINGS_NAMESPACE)
+)
+
+
+def test_kwargs_become_properties():
+ """Keyword arguments are stored as string values."""
+ settings = SdkSettings(force_gpu_mode="cuda", processing_thread_count=8)
+ assert dict(settings) == {
+ "force_gpu_mode": "cuda",
+ "processing_thread_count": "8",
+ }
+
+
+def test_none_values_are_dropped():
+ """A None value means 'not set' and never reaches the file."""
+ settings = SdkSettings(il_pos_min=None, il_pos_max=4)
+ assert "il_pos_min" not in settings
+ assert settings["il_pos_max"] == "4"
+
+
+def test_value_coercion():
+ """Booleans render as true/false, everything else via str()."""
+ settings = SdkSettings(a=True, b=False, c=3, d=1.5, e="text")
+ assert settings["a"] == "true"
+ assert settings["b"] == "false"
+ assert settings["c"] == "3"
+ assert settings["d"] == "1.5"
+ assert settings["e"] == "text"
+
+
+def test_enum_value_coercion():
+ """Enums are written as their value, so SDK enums can be passed directly."""
+ settings = SdkSettings(processing_mode=cuvis.ProcessingMode.Raw)
+ assert settings["processing_mode"] == str(cuvis.ProcessingMode.Raw.value)
+
+
+def test_mapping_interface():
+ """The full MutableMapping surface is available for inspection."""
+ settings = SdkSettings(force_gpu_mode="cuda")
+
+ settings["verbose"] = True
+ assert settings["verbose"] == "true"
+ assert len(settings) == 2
+ assert "verbose" in settings
+ assert sorted(settings) == ["force_gpu_mode", "verbose"]
+ assert sorted(settings.keys()) == ["force_gpu_mode", "verbose"]
+ assert dict(settings.items())["force_gpu_mode"] == "cuda"
+ assert settings.get("missing") is None
+ assert settings.get("missing", "fallback") == "fallback"
+
+ settings.update({"file_compression": "9"})
+ assert settings["file_compression"] == "9"
+
+ del settings["verbose"]
+ assert "verbose" not in settings
+
+
+def test_repr_contains_keys():
+ """repr shows the class name and the current mapping."""
+ text = repr(SdkSettings(force_gpu_mode="cuda"))
+ assert text.startswith("SdkSettings(")
+ assert "force_gpu_mode" in text
+ assert "cuda" in text
+
+
+def test_xml_str_structure():
+ """The serialized XML carries the declaration, namespace and version."""
+ xml = SdkSettings(force_gpu_mode="cuda").xml_str
+ assert xml.startswith("",
+ "missing_version": ''.format(SETTINGS_NAMESPACE),
+ "property_without_id": (
+ ''.format(
+ SETTINGS_NAMESPACE
+ )
+ ),
+ "duplicate_ids": (
+ ''
+ ''
+ "".format(SETTINGS_NAMESPACE)
+ ),
+ "unexpected_element": (
+ ''.format(
+ SETTINGS_NAMESPACE
+ )
+ ),
+ "malformed_xml": "