Files
orisys-app/shared/src/orisys_app_shared/paths.py
T
ykcheungab 25ccffd814 Discover sensor bundles from the SDK catalog, not a product list
The picker listed a hard-coded product list and treated any *.json beside sensor.json as a
configuration, so a folder of stray JSON could be offered as a sensor. Discovery now scans the
shipped root and a writable user root beside it, keeps the SDK's verdict for every entry
(listing rejected ones with their reason) and flags duplicate profile_ids instead of shadowing
them; verified by shared/tests and apps/studio/tests.
2026-10-05 09:30:04 +08:00

271 lines
8.9 KiB
Python

"""Resolve app, config, and output paths for editable and frozen runs.
Several desktop products share this package. Which one a process belongs to is
decided once, at start-up:
* ``ORISYS_APP_ID`` — the directory name under ``apps/`` (e.g. ``studio``,
``autocal``). Set it **before** importing anything from this package; an app
that changes it later gets whichever root was resolved first, because these
helpers are called during import of logging/prefs.
* Otherwise the historic behaviour applies, which resolves ``apps/studio``.
That keeps existing callers working without them passing anything.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
#: Environment variable naming the app directory under ``apps/``.
APP_ID_ENV = "ORISYS_APP_ID"
#: Fallback app when ``ORISYS_APP_ID`` is unset — the first product built here.
DEFAULT_APP_ID = "studio"
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def app_id() -> str:
"""The app directory name for this process."""
return os.environ.get(APP_ID_ENV, "").strip() or DEFAULT_APP_ID
def apps_dir() -> Path:
"""The ``apps/`` directory of the source tree."""
here = Path(__file__).resolve()
# .../orisys-app/shared/src/orisys_app_shared/paths.py -> .../orisys-app
repo_root = here.parents[3]
return repo_root / "apps"
def _default_app_root_from_shared(app_id_value: str) -> Path:
"""Repo layout: shared/src/orisys_app_shared -> apps/<app>."""
return apps_dir() / app_id_value
def app_root() -> Path:
"""Return this app's root (editable) or the EXE directory (frozen)."""
if is_frozen():
return Path(sys.executable).resolve().parent
wanted = app_id()
# Launched from inside an app directory (``python apps/<app>/run.py``).
cwd = Path.cwd().resolve()
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
return cwd
# Launched from the repo root.
candidate = cwd / "apps" / wanted
if (candidate / "config").is_dir():
return candidate
# A named app that is not where we expected must not silently fall back to
# another product's config: that would write one app's preferences into
# another's tree.
if wanted == DEFAULT_APP_ID:
return _default_app_root_from_shared(wanted)
unresolved = _default_app_root_from_shared(wanted)
if (unresolved / "config").is_dir():
return unresolved
raise FileNotFoundError(
f"{APP_ID_ENV}={wanted!r} does not name an app with a config directory "
f"(looked in {cwd / 'apps' / wanted} and {unresolved})"
)
def config_dir() -> Path:
if is_frozen():
beside = app_root() / "config"
if beside.is_dir():
return beside
internal = app_root() / "_internal" / "config"
if internal.is_dir():
return internal
return app_root() / "config"
def sensors_dir() -> Path:
return config_dir() / "sensors"
def user_sensors_dir() -> Path:
"""The writable user bundle root: a **sibling** of the shipped one.
The app ships as a portable folder, so the natural place for a bundle the
operator adds is beside the shipped ``config/sensors`` rather than inside it:
an upgrade that replaces the shipped tree must not delete their work, and a
zip that is unpacked somewhere else must carry it along.
This is only the preferred location. Whether it can be written — and where
the catalog goes when it cannot — is decided by
:func:`orisys_app_shared.sensors.bundle_catalog.resolve_user_root`, which
reports the root actually in use. Never place user bundles in the shipped
root, and never in Documents.
"""
return config_dir() / "sensors-user"
def fallback_user_sensors_dir() -> Path:
"""The per-user bundle root used when the portable sibling is not writable.
``%LOCALAPPDATA%\\OrisysStudio\\config\\sensors-user`` on Windows, and the
equivalent cache root elsewhere — the same derived-data area
:func:`derived_data_dir` already owns, so a read-only installation medium
still has somewhere writable to keep the operator's bundles.
"""
return derived_data_dir() / "config" / "sensors-user"
def locales_dir() -> Path:
return config_dir() / "locales"
def outputs_dir() -> Path:
path = app_root() / "outputs"
path.mkdir(parents=True, exist_ok=True)
return path
def logs_dir() -> Path:
path = outputs_dir() / "logs"
path.mkdir(parents=True, exist_ok=True)
return path
def calibration_export_dir() -> Path:
path = outputs_dir() / "calibration"
path.mkdir(parents=True, exist_ok=True)
return path
def recordings_dir() -> Path:
"""Root for one directory per recorded experiment."""
path = outputs_dir() / "recordings"
path.mkdir(parents=True, exist_ok=True)
return path
#: Directory name of the product's per-user data, under the OS's own root for it.
DERIVED_DIR_NAME = "OrisysStudio"
#: Subdirectory holding the offline analysis results.
ANALYSIS_CACHE_DIRNAME = "analysis-cache"
def derived_data_dir() -> Path:
"""The OS-correct per-user root for **derived** (rebuildable) data.
Windows: ``%LOCALAPPDATA%\\OrisysStudio`` (falling back to
``%USERPROFILE%\\AppData\\Local`` when the variable is unset — a service or a
stripped environment, where the fallback is still the right directory). Everywhere
else: ``$XDG_CACHE_HOME/OrisysStudio``, or ``~/.cache/OrisysStudio``.
Deliberately *not* :func:`outputs_dir`: in a packaged build that is the EXE's own
directory, and it holds the recordings. Anything the app can rebuild belongs in the
user's own derived-data area, where a reinstall cannot destroy it and where writing
it needs no permission on the installation.
"""
name = DERIVED_DIR_NAME
if os.name == "nt":
local = os.environ.get("LOCALAPPDATA", "").strip()
base = (
Path(local)
if local
else Path(os.environ.get("USERPROFILE", str(Path.home()))) / "AppData" / "Local"
)
return base / name
cache = os.environ.get("XDG_CACHE_HOME", "").strip()
return (Path(cache) if cache else Path.home() / ".cache") / name
def analysis_cache_dir() -> Path:
"""Where the offline analysis results live: the app's own root, never a recording."""
return derived_data_dir() / ANALYSIS_CACHE_DIRNAME
def preference_path() -> Path:
return app_root() / "preference.json"
def preferences_default_path() -> Path:
return config_dir() / "preferences.default.json"
def branding_dir() -> Path:
"""Branding assets shipped with the shared package."""
# editable: shared/resources/branding
# package: next to installed module via package resources fallback
here = Path(__file__).resolve()
candidates = [
here.parents[2] / "resources" / "branding", # shared/resources/branding
here.parent / "resources" / "branding",
]
if is_frozen():
candidates.insert(0, app_root() / "branding")
candidates.insert(0, app_root() / "_internal" / "branding")
for path in candidates:
if path.is_dir():
return path
return candidates[0]
def logo_path() -> Path:
return branding_dir() / "logo.png"
def logo_topbar_path() -> Path:
return branding_dir() / "logo_topbar.png"
def sensor_json_path(sensor_type: str) -> Path | None:
"""Return ``config/sensors/<type>/sensor.json`` when present."""
path = sensors_dir() / str(sensor_type) / "sensor.json"
return path if path.is_file() else None
_PREFERRED_SENSOR_ROOT: Path | None = None
def prefer_local_sensor_bundles(root: Path | None = None) -> Path | None:
"""Prefer app ``config/sensors`` over packaged Orisys sensor bundles."""
global _PREFERRED_SENSOR_ROOT
if root is not None:
sensors = (Path(root) / "config" / "sensors").resolve()
if not sensors.is_dir():
sensors = Path(root).resolve()
else:
sensors = sensors_dir().resolve()
if not sensors.is_dir():
return None
if _PREFERRED_SENSOR_ROOT is not None and _PREFERRED_SENSOR_ROOT == sensors:
return sensors
try:
import orisys.configs as configs
except Exception:
return None
original = configs._candidate_sensor_roots
marker = str(sensors)
if getattr(original, "_orisys_app_local_pref", None) == marker:
_PREFERRED_SENSOR_ROOT = sensors
return sensors
def _candidate_sensor_roots_prefer_local() -> list[Path]:
roots = [sensors]
for candidate in original():
resolved = Path(candidate).resolve()
if resolved != sensors and resolved not in roots:
roots.append(resolved)
return roots
_candidate_sensor_roots_prefer_local._orisys_app_local_pref = marker # type: ignore[attr-defined]
configs._candidate_sensor_roots = _candidate_sensor_roots_prefer_local
_PREFERRED_SENSOR_ROOT = sensors
return sensors