Files
orisys-sdk/src/orisys/configs.py
T
2026-09-27 19:21:55 +08:00

494 lines
17 KiB
Python

import json
import os
import shutil
import sys
from contextlib import ExitStack
from dataclasses import dataclass
from importlib.resources import as_file, files
from pathlib import Path
# Product-facing names that map to on-disk config stems.
# Public product stems are ``box.json`` and ``finger.json``.
# Keep older lab names working: ``dd02`` still resolves to ``dd02-ov``.
CONFIG_ALIASES = {
"dd02": "dd02-ov",
}
PRODUCT_BUNDLES = ("box", "mini", "finger")
_RESOURCE_STACK = ExitStack()
def _resource_path(*parts: str) -> Path | None:
"""Resolve a package resource to a filesystem path for wheel-safe loaders."""
try:
resource = files("orisys").joinpath(*parts)
resolved = _RESOURCE_STACK.enter_context(as_file(resource))
except Exception:
return None
# ``as_file`` is not contractually a ``pathlib.Path``: a Nuitka-compiled
# build hands back its own ``nuitka_resource_reader_files``, which is
# path-like but has no ``.resolve()``. Normalise here so every caller can
# treat the result as a Path.
try:
return Path(os.fspath(resolved))
except TypeError:
return None
def _unique_paths(paths) -> list[Path]:
"""Ordered, de-duplicated paths (Windows comparison is case-insensitive)."""
unique: list[Path] = []
seen: set[str] = set()
for path in paths:
path = Path(path) if not isinstance(path, Path) else path
try:
key = str(path.resolve()).casefold()
except (OSError, ValueError): # pragma: no cover - defensive
key = str(path).casefold()
if key in seen:
continue
seen.add(key)
unique.append(path)
return unique
def _is_sdk_runtime_root(path: Path) -> bool:
"""Whether ``path`` carries the runnable SDK layout.
The anchor is the **folder layout**, never the folder's name: a distribution
folder may be called anything (a release checkout, a renamed copy, an
unpacked zip), but it always ships ``examples/`` beside ``sensors/``. That is
what distinguishes a run root from an arbitrary directory, and it is why a
renamed root keeps working while ``dist/**`` (``sensors/`` without
``examples/``) is not mistaken for one.
"""
try:
if not path.is_dir():
return False
return (path / "examples").is_dir() and (path / "sensors").is_dir()
except OSError: # pragma: no cover - defensive: unreadable path
return False
def _runtime_roots() -> list[Path]:
"""Runnable SDK roots for this process, nearest to the launcher first.
Evaluated once at import time, so a process that changes directory later
keeps the answer it started with.
"""
anchors: list[Path] = []
def add(path: Path) -> None:
if path not in anchors:
anchors.append(path)
cwd = Path.cwd()
add(cwd)
# A script run from a subdirectory (``<root>/examples/foo.py``, or a caller
# that launched from ``<root>/scripts``) still names its root through argv[0].
script_dir: Path | None = None
try:
argv0 = sys.argv[0] if sys.argv else ""
if argv0:
candidate = Path(argv0).expanduser()
script_dir = candidate.resolve().parent if candidate.is_file() else None
except (OSError, ValueError): # pragma: no cover - defensive
script_dir = None
# Walk up from both, so the root is found however deep the launcher sits.
for start in (cwd, script_dir):
if start is None:
continue
for candidate in (start, *start.parents):
add(candidate)
# Roots that put themselves on sys.path (``python <root>/examples/foo.py``
# with the root as cwd, ``PYTHONPATH=<root>``, and similar).
for entry in list(sys.path):
if not entry:
continue
try:
add(Path(entry).expanduser())
except (OSError, ValueError): # pragma: no cover - defensive
continue
anchors = _unique_paths(anchors)
# The run root is the *nearest* runnable directory, walking up from the
# launcher: starting at the cwd, then the script's directory, then the
# entries sys.path. Order carries the meaning here -- containment does not.
# Scanning every anchor for the shortest match instead picked the shallowest
# ancestor, so a workspace that carried ``examples/`` alongside ``sensors/``
# absorbed the real root below it and its bundle shadowed that root's own.
roots: list[Path] = []
for path in anchors:
if not _is_sdk_runtime_root(path):
continue
if any(existing.is_relative_to(path) or path.is_relative_to(existing) for existing in roots):
# Same tree: only the nearest root counts.
continue
roots.append(path)
return roots
def _candidate_config_dirs() -> list[Path]:
"""Candidate config directories, the running SDK root first.
A run root's ``config/`` outranks the packaged copy so a bundle's
``sensor.json`` and its ``force_calibration.json`` come from the same place.
"""
here = Path(__file__).resolve().parent
return _unique_paths([
*(root / "config" for root in _runtime_roots()),
here / "configs", # packaged wheel layout: orisys/configs/*.json
here.parent.parent / "config", # editable install from repo: repo_root/config/*.json
here.parent / "config", # fallback
Path.cwd() / "config", # script launched from repo root
])
def _candidate_sensor_roots() -> list[Path]:
"""Candidate roots that contain per-sensor bundle folders.
Lookup order:
1. the ``sensors/`` of every runnable SDK root found at startup, nearest
first -- so the bundle under the directory that is actually running wins,
whatever that directory is called;
2. the packaged resource ``orisys/sensors`` (wheel installs);
3. the module-relative candidates (editable checkout, packaged tree);
4. the current working directory, last, as a safety net.
"""
here = Path(__file__).resolve().parent
candidates = [root / "sensors" for root in _runtime_roots()]
packaged = _resource_path("sensors")
if packaged is not None:
candidates.append(packaged) # packaged wheel layout: orisys/sensors/
candidates.extend([
here / "sensors",
here.parent.parent / "sensors", # editable install: repo_root/sensors/
here.parent / "sensors",
Path.cwd() / "sensors",
])
return _unique_paths(candidates)
def _get_config_dir() -> Path:
for candidate in _candidate_config_dirs():
if candidate.exists() and any(candidate.glob("*.json")):
return candidate
# Default to the first candidate so error messages are stable.
return _candidate_config_dirs()[0]
CONFIG_DIR = _get_config_dir()
def _config_files() -> list[Path]:
if not CONFIG_DIR.exists():
return []
return sorted(CONFIG_DIR.glob("*.json"))
def _load_json(path: Path) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
@dataclass(frozen=True)
class SensorBundle:
"""Resolved paths for one additive per-sensor package folder."""
name: str
root: Path
sensor_json: Path
force_calibration_json: Path | None = None
manifest_json: Path | None = None
assets_dir: Path | None = None
def resolve_sensor_bundle(name: str) -> SensorBundle | None:
"""Resolve ``sensors/<name>/`` when present; otherwise return ``None``."""
requested = CONFIG_ALIASES.get(str(name), str(name))
for root in _candidate_sensor_roots():
bundle_root = root / requested
sensor_json = bundle_root / "sensor.json"
if not sensor_json.is_file():
continue
force_path = bundle_root / "force_calibration.json"
manifest_path = bundle_root / "manifest.json"
assets_dir = None
if manifest_path.is_file():
try:
manifest = _load_json(manifest_path)
except Exception:
manifest = {}
rel_assets = manifest.get("assets_dir")
if isinstance(rel_assets, str) and rel_assets.strip():
candidate = (bundle_root / rel_assets).resolve()
if candidate.is_dir():
assets_dir = candidate
return SensorBundle(
name=requested,
root=bundle_root.resolve(),
sensor_json=sensor_json.resolve(),
force_calibration_json=force_path.resolve() if force_path.is_file() else None,
manifest_json=manifest_path.resolve() if manifest_path.is_file() else None,
assets_dir=assets_dir,
)
return None
def describe_bundle_resolution(name: str) -> dict:
"""Explain how ``name`` resolved, so a missing calibration stops being silent.
A bundle can resolve and still carry no ``force_calibration.json``: the
lookup stops at the first root that has ``<name>/sensor.json`` and never
asks whether that root also has the coefficients. Reporting which bundle was
found -- and which roots were passed over -- is what makes that case
distinguishable from "no bundle at all", which is otherwise what the caller
sees.
Returns ``name``, the ``searched`` roots in order, and the first root that
supplied ``<name>/sensor.json`` together with whether it carried a
``force_calibration.json``.
"""
requested = CONFIG_ALIASES.get(str(name), str(name))
searched = _candidate_sensor_roots()
for root in searched:
sensor_json = root / requested / "sensor.json"
if not sensor_json.is_file():
continue
return {
"name": requested,
"searched": searched,
"found_root": sensor_json.parent,
"sensor_json": sensor_json,
"force_calibration_json": (
sensor_json.parent / "force_calibration.json"
).is_file(),
}
return {
"name": requested,
"searched": searched,
"found_root": None,
"sensor_json": None,
"force_calibration_json": False,
}
def resolve_sensor_config_path(name: str) -> Path | None:
"""Return ``sensor.json`` for a resolved sensor bundle."""
bundle = resolve_sensor_bundle(name)
return bundle.sensor_json if bundle is not None else None
def resolve_sensor_asset_dir(name: str) -> Path | None:
"""Return the default asset directory for a resolved sensor bundle."""
bundle = resolve_sensor_bundle(name)
return bundle.assets_dir if bundle is not None else None
def resolve_sensor_resource(name: str, *relative_parts: str) -> Path | None:
"""Return a path inside one sensor bundle when present."""
bundle = resolve_sensor_bundle(name)
if bundle is None:
return None
return bundle.root.joinpath(*relative_parts)
def list_sensor_bundles() -> list[str]:
"""Return product names that have an additive ``sensors/<name>/`` folder."""
found: list[str] = []
for name in PRODUCT_BUNDLES:
if resolve_sensor_bundle(name) is not None:
found.append(name)
return found
def load_force_calibration(name: str) -> dict | None:
"""Load ``force_calibration.json`` from a sensor bundle when available."""
bundle = resolve_sensor_bundle(name)
if bundle is None or bundle.force_calibration_json is None:
return None
return _load_json(bundle.force_calibration_json)
_BUNDLE_JSON_FILES = ("sensor.json", "force_calibration.json", "manifest.json")
def export_sensor_bundle(
name: str,
dest_dir: str | Path,
*,
include_assets: bool = False,
overwrite: bool = True,
) -> Path:
"""Copy one resolved ``sensors/<name>/`` bundle into ``dest_dir/<name>/``.
By default only JSON metadata is copied (``sensor.json``, and when present
``force_calibration.json`` / ``manifest.json``). Pass ``include_assets=True``
to copy the full tree (including ``assets/``) for packaging finger meshes.
"""
bundle = resolve_sensor_bundle(name)
if bundle is None:
available = ", ".join(list_sensor_bundles()) or "none"
raise FileNotFoundError(
f"Sensor bundle not found: {name!r}. Available: {available}"
)
dest_root = Path(dest_dir).expanduser().resolve()
dest = dest_root / bundle.name
if dest.exists():
if not overwrite:
raise FileExistsError(f"Destination already exists: {dest}")
if dest.is_dir():
shutil.rmtree(dest)
else:
dest.unlink()
if include_assets:
shutil.copytree(bundle.root, dest)
return dest
dest.mkdir(parents=True, exist_ok=True)
for filename in _BUNDLE_JSON_FILES:
src = bundle.root / filename
if src.is_file():
shutil.copy2(src, dest / filename)
if not (dest / "sensor.json").is_file():
raise FileNotFoundError(f"sensor.json missing after export from {bundle.root}")
return dest
def export_sensor_bundles(
dest_dir: str | Path,
*,
names: list[str] | tuple[str, ...] | None = None,
include_assets: bool = False,
overwrite: bool = True,
) -> list[Path]:
"""Export several sensor bundles into ``dest_dir``.
When ``names`` is omitted, exports every name in ``PRODUCT_BUNDLES`` that
currently resolves via :func:`resolve_sensor_bundle`.
"""
if names is None:
selected = [name for name in PRODUCT_BUNDLES if resolve_sensor_bundle(name) is not None]
else:
selected = [str(name) for name in names]
if not selected:
raise FileNotFoundError("No sensor bundles selected for export")
exported: list[Path] = []
for name in selected:
exported.append(
export_sensor_bundle(
name,
dest_dir,
include_assets=include_assets,
overwrite=overwrite,
)
)
return exported
def load_config(config_name_or_path):
"""
Load sensor configuration from a JSON file path or config name.
Config lookup order:
1. Exact file path (absolute or relative)
2. Additive sensor bundle ``sensors/<name>/sensor.json``
3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
"""
config_path = Path(config_name_or_path)
if config_path.exists() and config_path.is_file():
if config_path.suffix.lower() != ".json":
raise ValueError(f"Config file must be a JSON file: {config_path}")
return _load_json(config_path)
if config_path.is_absolute():
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}")
requested = str(config_name_or_path)
candidates = [requested]
alias = CONFIG_ALIASES.get(requested)
if alias and alias not in candidates:
candidates.append(alias)
for name in candidates:
bundle = resolve_sensor_bundle(name)
if bundle is not None:
return _load_json(bundle.sensor_json)
for name in candidates:
direct = CONFIG_DIR / f"{name}.json"
if direct.exists():
return _load_json(direct)
# Backward-compatible alias resolution: support old names via JSON "name" field.
for candidate in _config_files():
try:
data = _load_json(candidate)
except Exception: # noqa: S112 - skip unreadable config during alias scan
continue
if data.get("name") in candidates:
return data
available = [f.stem for f in _config_files()]
available_str = ", ".join(available) if available else "none"
raise FileNotFoundError(
f"Input config file not found with name or path: {config_name_or_path}\n"
f" Tried as file path: {config_path} (not found)\n"
f" Tried as config files: {', '.join(candidates)} (not found)\n"
f" Available config files: {available_str}"
)
def list_config():
"""
List all available JSON configuration files by file stem.
"""
stems = [f.stem for f in _config_files()]
for name in list_sensor_bundles():
if name not in stems:
stems.append(name)
return sorted(stems)
def export_config(config_name=None, path="./config/exported_config.json"):
"""
Export a configuration to a JSON file with readable formatting.
"""
config = load_config(config_name)
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
formatted = _format_json_value(config)
f.write(formatted)
f.write("\n")
print(f"Config name: {config_name} exported to {path}")
return config
def _format_json_value(value, indent_level=0, indent_str=" "):
if isinstance(value, dict):
if not value:
return "{}"
items = []
for k, v in value.items():
key_str = json.dumps(k, ensure_ascii=False)
value_str = _format_json_value(v, indent_level + 1, indent_str)
items.append(f"{indent_str * (indent_level + 1)}{key_str}: {value_str}")
return "{\n" + ",\n".join(items) + "\n" + indent_str * indent_level + "}"
if isinstance(value, list):
if not value:
return "[]"
items = [json.dumps(item, ensure_ascii=False) for item in value]
return "[" + ", ".join(items) + "]"
return json.dumps(value, ensure_ascii=False)