import json 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) return _RESOURCE_STACK.enter_context(as_file(resource)) except Exception: 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: try: key = str(path.resolve()).casefold() except OSError: # pragma: no cover - defensive: unresolvable path 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 (``/examples/foo.py``, or a caller # that launched from ``/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 /examples/foo.py`` # with the root as cwd, ``PYTHONPATH=``, 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, "r", 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//`` 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 ``/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 ``/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//`` 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//`` bundle into ``dest_dir//``. 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//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: 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)