25ccffd814
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.
271 lines
8.9 KiB
Python
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
|