"""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/.""" 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//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//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