From 12e3383eaf9510dc0591611407d3bf67ea40d0ee Mon Sep 17 00:00:00 2001 From: author31 Date: Mon, 16 Mar 2026 19:27:58 +0800 Subject: [PATCH] [Code] add session browsing APIs --- AGENTS.md | 5 +- pyproject.toml | 1 + src/opai/__init__.py | 15 +- src/opai/application/__init__.py | 8 +- src/opai/application/calibration.py | 14 +- src/opai/application/session.py | 106 ++++++++++++++ src/opai/core/exceptions.py | 10 +- src/opai/domain/__init__.py | 9 +- src/opai/domain/calibration.py | 9 +- src/opai/domain/context.py | 3 + src/opai/domain/session.py | 25 ++++ src/opai/infrastructure/__init__.py | 23 ++- src/opai/infrastructure/context_store.py | 67 ++++++++- src/opai/infrastructure/persistence.py | 120 +++++++++++++++ src/opai/presentation/__init__.py | 22 ++- src/opai/presentation/facade.py | 80 +++++++++- tests/test_calibration.py | 5 +- tests/test_facade.py | 177 ++++++++++++++++++++++- uv.lock | 38 ++++- 19 files changed, 699 insertions(+), 38 deletions(-) create mode 100644 src/opai/application/session.py create mode 100644 src/opai/domain/session.py diff --git a/AGENTS.md b/AGENTS.md index cc4496e..a30160a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Users interact with `opai` directly from notebook cells. The package root is the - Resolves the active global context and forwards calls into the application layer. ## Public API Design -The public API should be short and stable. Prefer this style: +The public API should be short and stable. Prefer this style (examples): ```python ctx = opai.init(name: str) -> Context @@ -144,6 +144,9 @@ Outcome: - Make artifact creation deterministic where possible. - Make errors notebook-friendly and actionable. - Fail fast on required dependencies. If a feature depends on a package that is required by this repo, import it normally and let missing dependencies fail at import time rather than adding deferred runtime guards. Reserve lazy imports or fallback guards for truly optional integrations only. +- Do not import types from the `typing` module. Python 3.10+ native annotations are the repo standard. +- Prefer built-in generics such as `list[str]`, `dict[str, object]`, `tuple[int, ...]`, and unions like `Path | None`. +- When a protocol-style annotation is needed, import it from `collections.abc` instead of `typing`. ## Open Design Points To Resolve In Implementation These decisions must be made consistently across the package: diff --git a/pyproject.toml b/pyproject.toml index f0221fc..a7ec117 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.10" dependencies = [ "jupyterlab>=4.5.6", "opencv-contrib-python>=4.13.0.92", + "rich>=14.1.0", ] [project.optional-dependencies] diff --git a/src/opai/__init__.py b/src/opai/__init__.py index d10ab94..0caa38d 100644 --- a/src/opai/__init__.py +++ b/src/opai/__init__.py @@ -1,8 +1,21 @@ -from opai.presentation.facade import calibrate, get_context, init, main +from opai.presentation.facade import ( + add_demos, + add_mapping, + browse_session, + calibrate, + get_context, + init, + list_sessions, + main, +) __all__ = [ + "add_demos", + "add_mapping", + "browse_session", "calibrate", "get_context", "init", + "list_sessions", "main", ] diff --git a/src/opai/application/__init__.py b/src/opai/application/__init__.py index d8f3e4a..6bc7c4b 100644 --- a/src/opai/application/__init__.py +++ b/src/opai/application/__init__.py @@ -1,3 +1,9 @@ from opai.application.calibration import calibrate +from opai.application.session import ( + add_demos, + add_mapping, + browse_session, + list_sessions, +) -__all__ = ["calibrate"] +__all__ = ["add_demos", "add_mapping", "browse_session", "calibrate", "list_sessions"] diff --git a/src/opai/application/calibration.py b/src/opai/application/calibration.py index 13536f8..c10e807 100644 --- a/src/opai/application/calibration.py +++ b/src/opai/application/calibration.py @@ -1,4 +1,6 @@ -from typing import Any, List, Sequence, Tuple +from __future__ import annotations + +from collections.abc import Sequence import cv2 import numpy as np @@ -37,8 +39,8 @@ def calibrate( image_height, image_width = _get_frame_size(frames) image_size = (image_width, image_height) - all_charuco_corners = [] # type: List[np.ndarray] - all_charuco_ids = [] # type: List[np.ndarray] + all_charuco_corners: list[np.ndarray] = [] + all_charuco_ids: list[np.ndarray] = [] for frame in frames: grayscale = _to_grayscale(frame) @@ -124,14 +126,14 @@ def _validate_inputs( ) -def _resolve_dictionary(name: str) -> Any: +def _resolve_dictionary(name: str) -> cv2.aruco.Dictionary: dictionary_id = getattr(cv2.aruco, name, None) if dictionary_id is None: raise OPAIValidationError(f"Unsupported ArUco dictionary: {name}") return cv2.aruco.getPredefinedDictionary(dictionary_id) -def _get_frame_size(frames: Sequence[np.ndarray]) -> Tuple[int, int]: +def _get_frame_size(frames: Sequence[np.ndarray]) -> tuple[int, int]: height, width = frames[0].shape[:2] return int(height), int(width) @@ -143,7 +145,7 @@ def _to_grayscale(frame: np.ndarray) -> np.ndarray: def _compute_mse_reprojection_error( - board: Any, + board: cv2.aruco.CharucoBoard, charuco_corners: Sequence[np.ndarray], charuco_ids: Sequence[np.ndarray], rvecs: Sequence[np.ndarray], diff --git a/src/opai/application/session.py b/src/opai/application/session.py new file mode 100644 index 0000000..62fbcd0 --- /dev/null +++ b/src/opai/application/session.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +from opai.core.exceptions import OPAIValidationError +from opai.domain.context import Context +from opai.domain.session import DemoAsset, SessionManifest +from opai.infrastructure.context_store import ( + get_session_directory, + list_session_names, + load_manifest_for_context, + persist_manifest_for_context, +) +from opai.infrastructure.persistence import ( + build_file_tree, + copy_demo_assets, + copy_mapping_asset, + list_relative_paths, +) + + +def add_demos(ctx: Context, video_paths: Iterable[str | Path]) -> tuple[DemoAsset, ...]: + source_paths = _normalize_video_paths(video_paths, label="demo") + manifest = load_manifest_for_context(ctx) + copied_assets = copy_demo_assets( + ctx.session_directory, + current_demos=manifest.demos, + source_paths=source_paths, + ) + updated_manifest = SessionManifest( + session_name=manifest.session_name, + demos=manifest.demos + copied_assets, + mapping=manifest.mapping, + ) + persist_manifest_for_context(ctx, updated_manifest) + return copied_assets + + +def add_mapping(ctx: Context, video_path: str | Path) -> MappingAsset: + source_path = _normalize_single_video_path(video_path, label="mapping") + manifest = load_manifest_for_context(ctx) + mapping_asset = copy_mapping_asset(ctx.session_directory, source_path) + updated_manifest = SessionManifest( + session_name=manifest.session_name, + demos=manifest.demos, + mapping=mapping_asset, + ) + persist_manifest_for_context(ctx, updated_manifest) + return mapping_asset + + +def list_sessions() -> list[str]: + return list_session_names() + + +def browse_session(name: str) -> tuple[list[str], dict[str, dict]]: + session_name = _normalize_session_name(name) + session_directory = get_session_directory(session_name) + if not session_directory.exists(): + raise OPAIValidationError( + f"Session '{session_name}' does not exist.", + details={"session_name": session_name}, + ) + return list_relative_paths(session_directory), build_file_tree(session_directory) + + +def _normalize_video_paths( + video_paths: Iterable[str | Path], + *, + label: str, +) -> tuple[Path, ...]: + normalized = tuple( + _coerce_existing_file_path(path, label=label) for path in video_paths + ) + if not normalized: + raise OPAIValidationError( + f"At least one {label} video path is required.", + details={"label": label}, + ) + return normalized + + +def _normalize_single_video_path(video_path: str | Path, *, label: str) -> Path: + return _coerce_existing_file_path(video_path, label=label) + + +def _coerce_existing_file_path(value: str | Path, *, label: str) -> Path: + path = Path(value).expanduser() + if not path.exists(): + raise OPAIValidationError( + f"{label.capitalize()} video does not exist: {path}", + details={"path": str(path)}, + ) + if not path.is_file(): + raise OPAIValidationError( + f"{label.capitalize()} path must point to a file: {path}", + details={"path": str(path)}, + ) + return path + + +def _normalize_session_name(name: str) -> str: + if not isinstance(name, str) or not name.strip(): + raise OPAIValidationError("Session name must be a non-empty string.") + return name.strip() diff --git a/src/opai/core/exceptions.py b/src/opai/core/exceptions.py index 890cb17..37d5146 100644 --- a/src/opai/core/exceptions.py +++ b/src/opai/core/exceptions.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from __future__ import annotations class OPAIError(Exception): @@ -9,15 +9,17 @@ class OPAIError(Exception): def __init__( self, message: str, - error_code: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, + error_code: str | None = None, + details: dict[str, str | int | float | bool | None] | None = None, ) -> None: self.message = message self.error_code = error_code or self.default_error_code self.details = details or {} super().__init__(message) - def to_dict(self) -> Dict[str, Any]: + def to_dict( + self, + ) -> dict[str, str | dict[str, str | int | float | bool | None]]: return { "error_code": self.error_code, "message": self.message, diff --git a/src/opai/domain/__init__.py b/src/opai/domain/__init__.py index 49cb646..3857468 100644 --- a/src/opai/domain/__init__.py +++ b/src/opai/domain/__init__.py @@ -1,4 +1,11 @@ from opai.domain.calibration import CalibrationResult from opai.domain.context import Context +from opai.domain.session import DemoAsset, MappingAsset, SessionManifest -__all__ = ["CalibrationResult", "Context"] +__all__ = [ + "CalibrationResult", + "Context", + "DemoAsset", + "MappingAsset", + "SessionManifest", +] diff --git a/src/opai/domain/calibration.py b/src/opai/domain/calibration.py index 619a0ba..b0b4ffa 100644 --- a/src/opai/domain/calibration.py +++ b/src/opai/domain/calibration.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import Any + +import numpy as np @dataclass @@ -22,5 +25,5 @@ class CalibrationResult: image_width: int intrinsic_type: str intrinsics: CalibrationIntrinsics - camera_matrix: Any - dist_coeffs: Any + camera_matrix: np.ndarray + dist_coeffs: np.ndarray diff --git a/src/opai/domain/context.py b/src/opai/domain/context.py index 17aded3..c37a268 100644 --- a/src/opai/domain/context.py +++ b/src/opai/domain/context.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from pathlib import Path @@ -6,3 +8,4 @@ class Context: name: str session_directory: Path + manifest_path: Path | None = None diff --git a/src/opai/domain/session.py b/src/opai/domain/session.py new file mode 100644 index 0000000..7a4339a --- /dev/null +++ b/src/opai/domain/session.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DemoAsset: + demo_id: str + source_path: str + stored_path: str + original_filename: str + + +@dataclass(frozen=True) +class MappingAsset: + source_path: str + stored_path: str + original_filename: str + + +@dataclass(frozen=True) +class SessionManifest: + session_name: str + demos: tuple[DemoAsset, ...] + mapping: MappingAsset | None = None diff --git a/src/opai/infrastructure/__init__.py b/src/opai/infrastructure/__init__.py index 9865ed5..e67b9a8 100644 --- a/src/opai/infrastructure/__init__.py +++ b/src/opai/infrastructure/__init__.py @@ -1,4 +1,21 @@ -from opai.infrastructure.context_store import get_active_context, init_context -from opai.infrastructure.persistence import write_calibration_result +from opai.infrastructure.context_store import ( + get_active_context, + get_session_directory, + init_context, + list_session_names, +) +from opai.infrastructure.persistence import ( + load_session_manifest, + write_calibration_result, + write_session_manifest, +) -__all__ = ["get_active_context", "init_context", "write_calibration_result"] +__all__ = [ + "get_active_context", + "get_session_directory", + "init_context", + "list_session_names", + "load_session_manifest", + "write_calibration_result", + "write_session_manifest", +] diff --git a/src/opai/infrastructure/context_store.py b/src/opai/infrastructure/context_store.py index eda3cad..1ce4375 100644 --- a/src/opai/infrastructure/context_store.py +++ b/src/opai/infrastructure/context_store.py @@ -1,19 +1,74 @@ +from __future__ import annotations + from pathlib import Path -from typing import Optional from opai.domain.context import Context +from opai.domain.session import SessionManifest +from opai.infrastructure.persistence import ( + load_session_manifest, + write_session_manifest, +) -_ACTIVE_CONTEXT = None # type: Optional[Context] +_ACTIVE_CONTEXT: Context | None = None +SESSION_ROOT_DIRNAME = ".opai_sessions" +SESSION_MANIFEST_FILENAME = "session.json" def init_context(name: str) -> Context: - session_directory = Path.cwd() / ".opai_sessions" / name - session_directory.mkdir(parents=True, exist_ok=True) + session_directory = session_root() / name + manifest_path = session_directory / SESSION_MANIFEST_FILENAME + _ensure_session_structure(session_directory) + manifest = load_session_manifest(manifest_path, session_name=name) + write_session_manifest(manifest_path, manifest) global _ACTIVE_CONTEXT - _ACTIVE_CONTEXT = Context(name=name, session_directory=session_directory) + _ACTIVE_CONTEXT = Context( + name=manifest.session_name, + session_directory=session_directory, + manifest_path=manifest_path, + ) return _ACTIVE_CONTEXT -def get_active_context() -> Optional[Context]: +def get_active_context() -> Context | None: return _ACTIVE_CONTEXT + + +def list_session_names() -> list[str]: + root = session_root() + if not root.exists(): + return [] + return sorted( + path.name + for path in root.iterdir() + if path.is_dir() and not path.name.startswith(".") + ) + + +def load_manifest_for_context(ctx: Context) -> SessionManifest: + manifest_path = _require_manifest_path(ctx) + return load_session_manifest(manifest_path, session_name=ctx.name) + + +def persist_manifest_for_context(ctx: Context, manifest: SessionManifest) -> Path: + manifest_path = _require_manifest_path(ctx) + return write_session_manifest(manifest_path, manifest) + + +def get_session_directory(name: str) -> Path: + return session_root() / name + + +def session_root() -> Path: + return Path.cwd() / SESSION_ROOT_DIRNAME + + +def _ensure_session_structure(session_directory: Path) -> None: + (session_directory / "captures" / "demos").mkdir(parents=True, exist_ok=True) + (session_directory / "captures" / "mapping").mkdir(parents=True, exist_ok=True) + + +def _require_manifest_path(ctx: Context) -> Path: + if ctx.manifest_path is not None: + return ctx.manifest_path + return ctx.session_directory / SESSION_MANIFEST_FILENAME diff --git a/src/opai/infrastructure/persistence.py b/src/opai/infrastructure/persistence.py index 368f76f..49e6149 100644 --- a/src/opai/infrastructure/persistence.py +++ b/src/opai/infrastructure/persistence.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import json +import shutil +from collections.abc import Sequence from pathlib import Path from opai.domain.calibration import CalibrationResult +from opai.domain.session import DemoAsset, MappingAsset, SessionManifest def write_calibration_result( @@ -30,3 +35,118 @@ def write_calibration_result( output_path = session_directory / filename output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") return output_path + + +def load_session_manifest(manifest_path: Path, session_name: str) -> SessionManifest: + if not manifest_path.exists(): + return SessionManifest(session_name=session_name, demos=(), mapping=None) + + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + demos = tuple( + DemoAsset( + demo_id=entry["demo_id"], + source_path=entry["source_path"], + stored_path=entry["stored_path"], + original_filename=entry["original_filename"], + ) + for entry in payload.get("demos", []) + ) + mapping_payload = payload.get("mapping") + mapping = None + if mapping_payload is not None: + mapping = MappingAsset( + source_path=mapping_payload["source_path"], + stored_path=mapping_payload["stored_path"], + original_filename=mapping_payload["original_filename"], + ) + return SessionManifest( + session_name=payload.get("session_name", session_name), + demos=demos, + mapping=mapping, + ) + + +def write_session_manifest(manifest_path: Path, manifest: SessionManifest) -> Path: + payload = { + "session_name": manifest.session_name, + "demos": [ + { + "demo_id": demo.demo_id, + "source_path": demo.source_path, + "stored_path": demo.stored_path, + "original_filename": demo.original_filename, + } + for demo in manifest.demos + ], + "mapping": None, + } + if manifest.mapping is not None: + payload["mapping"] = { + "source_path": manifest.mapping.source_path, + "stored_path": manifest.mapping.stored_path, + "original_filename": manifest.mapping.original_filename, + } + + manifest_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return manifest_path + + +def copy_demo_assets( + session_directory: Path, + current_demos: Sequence[DemoAsset], + source_paths: Sequence[Path], +) -> tuple[DemoAsset, ...]: + demo_root = session_directory / "captures" / "demos" + next_index = len(current_demos) + 1 + copied_assets: list[DemoAsset] = [] + for source_path in source_paths: + demo_id = f"demo-{next_index:04d}" + next_index += 1 + destination_directory = demo_root / demo_id + destination_directory.mkdir(parents=True, exist_ok=False) + destination_path = destination_directory / source_path.name + shutil.copy2(source_path, destination_path) + copied_assets.append( + DemoAsset( + demo_id=demo_id, + source_path=str(source_path.resolve()), + stored_path=str(destination_path.relative_to(session_directory)), + original_filename=source_path.name, + ) + ) + return tuple(copied_assets) + + +def copy_mapping_asset(session_directory: Path, source_path: Path) -> MappingAsset: + mapping_root = session_directory / "captures" / "mapping" / "current" + if mapping_root.exists(): + shutil.rmtree(mapping_root) + mapping_root.mkdir(parents=True, exist_ok=True) + destination_path = mapping_root / source_path.name + shutil.copy2(source_path, destination_path) + return MappingAsset( + source_path=str(source_path.resolve()), + stored_path=str(destination_path.relative_to(session_directory)), + original_filename=source_path.name, + ) + + +def list_relative_paths(session_directory: Path) -> list[str]: + entries: list[str] = [] + for path in sorted( + session_directory.rglob("*"), + key=lambda candidate: candidate.relative_to(session_directory).as_posix(), + ): + if path.is_file(): + entries.append(path.relative_to(session_directory).as_posix()) + return entries + + +def build_file_tree(session_directory: Path) -> dict[str, dict]: + tree: dict[str, dict] = {} + for relative_path in list_relative_paths(session_directory): + parts = relative_path.split("/") + cursor = tree + for part in parts: + cursor = cursor.setdefault(part, {}) + return tree diff --git a/src/opai/presentation/__init__.py b/src/opai/presentation/__init__.py index e7f179f..0caa38d 100644 --- a/src/opai/presentation/__init__.py +++ b/src/opai/presentation/__init__.py @@ -1,3 +1,21 @@ -from opai.presentation.facade import calibrate, get_context, init, main +from opai.presentation.facade import ( + add_demos, + add_mapping, + browse_session, + calibrate, + get_context, + init, + list_sessions, + main, +) -__all__ = ["calibrate", "get_context", "init", "main"] +__all__ = [ + "add_demos", + "add_mapping", + "browse_session", + "calibrate", + "get_context", + "init", + "list_sessions", + "main", +] diff --git a/src/opai/presentation/facade.py b/src/opai/presentation/facade.py index af4d59e..738338b 100644 --- a/src/opai/presentation/facade.py +++ b/src/opai/presentation/facade.py @@ -1,4 +1,10 @@ -from typing import Any, Sequence +from __future__ import annotations + +import re +from collections.abc import Sequence +from pathlib import Path + +import numpy as np from opai.core.exceptions import ( OPAIContextError, @@ -7,13 +13,15 @@ ) from opai.domain.calibration import CalibrationResult from opai.domain.context import Context +from opai.domain.session import DemoAsset, MappingAsset from opai.infrastructure.context_store import get_active_context, init_context +_SESSION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + def init(name: str) -> Context: - if not name.strip(): - raise OPAIValidationError("Session name must be a non-empty string.") - return init_context(name) + normalized_name = _normalize_session_name(name) + return init_context(normalized_name) def get_context() -> Context: @@ -26,7 +34,7 @@ def get_context() -> Context: def calibrate( - frames: Sequence[Any], + frames: Sequence[np.ndarray], row_count: int, col_count: int, square_length: float, @@ -53,5 +61,65 @@ def calibrate( ) +def add_demos(video_paths: Sequence[str | Path]) -> tuple[DemoAsset, ...]: + ctx = get_context() + from opai.application.session import add_demos as add_demos_with_context + + return add_demos_with_context(ctx, video_paths) + + +def add_mapping(video_path: str | Path) -> MappingAsset: + ctx = get_context() + from opai.application.session import add_mapping as add_mapping_with_context + + return add_mapping_with_context(ctx, video_path) + + +def list_sessions() -> list[str]: + from opai.application.session import list_sessions as list_available_sessions + + return list_available_sessions() + + +def browse_session(name: str) -> list[str]: + normalized_name = _normalize_session_name(name) + try: + from rich.console import Console + from rich.tree import Tree + except ModuleNotFoundError as exc: + raise OPAIDependencyError( + "Session browsing requires the 'rich' package. Install project dependencies before calling opai.browse_session(...)." + ) from exc + + from opai.application.session import browse_session as browse_named_session + + file_paths, tree_payload = browse_named_session(normalized_name) + tree = Tree(normalized_name) + _append_tree_nodes(tree, tree_payload) + Console().print(tree) + return file_paths + + def main() -> None: - print("Use opai.init(name) and opai.calibrate(...) from Python.") + print( + "Use opai.init(name), opai.add_demos(...), opai.add_mapping(...), and opai.calibrate(...) from Python." + ) + + +def _normalize_session_name(name: str) -> str: + if not isinstance(name, str) or not name.strip(): + raise OPAIValidationError("Session name must be a non-empty string.") + normalized_name = name.strip() + if not _SESSION_NAME_PATTERN.fullmatch(normalized_name): + raise OPAIValidationError( + "Session name may only contain letters, numbers, '.', '_' and '-'.", + details={"session_name": normalized_name}, + ) + return normalized_name + + +def _append_tree_nodes(tree, payload: dict[str, dict]) -> None: + for name, child in payload.items(): + branch = tree.add(name) + if isinstance(child, dict): + _append_tree_nodes(branch, child) diff --git a/tests/test_calibration.py b/tests/test_calibration.py index 5937dca..62e4267 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -1,5 +1,6 @@ +from __future__ import annotations + from types import SimpleNamespace -from typing import Tuple import numpy as np import pytest @@ -79,7 +80,7 @@ def test_compute_mse_reprojection_error_averages_squared_pixel_error( observed_corners = [np.array([[[1.0, 1.0]], [[4.0, 4.0]]], dtype=np.float32)] observed_ids = [np.array([[0], [1]], dtype=np.int32)] - def fake_project_points(**_: object) -> Tuple[np.ndarray, None]: + def fake_project_points(**_: np.ndarray) -> tuple[np.ndarray, None]: return np.array([[[2.0, 3.0]], [[5.0, 6.0]]], dtype=np.float32), None monkeypatch.setattr( diff --git a/tests/test_facade.py b/tests/test_facade.py index 5b35e13..5b4e800 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -1,4 +1,6 @@ import json +import sys +from builtins import __import__ as builtin_import from types import SimpleNamespace import numpy as np @@ -6,7 +8,12 @@ import opai from opai.application import calibration as calibration_module -from opai.core.exceptions import OPAIContextError +from opai.core.exceptions import ( + OPAIContextError, + OPAIDependencyError, + OPAIValidationError, +) +from opai.infrastructure import context_store def test_calibrate_requires_context() -> None: @@ -20,6 +27,47 @@ def test_init_creates_context_directory(tmp_path, monkeypatch) -> None: assert ctx.name == "session-001" assert ctx.session_directory.exists() + assert ( + ctx.manifest_path + == tmp_path / ".opai_sessions" / "session-001" / "session.json" + ) + assert ctx.manifest_path.exists() + assert (ctx.session_directory / "captures" / "demos").exists() + assert (ctx.session_directory / "captures" / "mapping").exists() + + +def test_init_resumes_existing_session_manifest(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + session_dir = tmp_path / ".opai_sessions" / "session-001" + session_dir.mkdir(parents=True) + manifest_path = session_dir / "session.json" + manifest_path.write_text( + json.dumps( + { + "session_name": "session-001", + "demos": [ + { + "demo_id": "demo-0001", + "source_path": "/tmp/source.mp4", + "stored_path": "captures/demos/demo-0001/source.mp4", + "original_filename": "source.mp4", + } + ], + "mapping": None, + } + ), + encoding="utf-8", + ) + + ctx = opai.init("session-001") + + payload = json.loads(ctx.manifest_path.read_text(encoding="utf-8")) + assert payload["demos"][0]["demo_id"] == "demo-0001" + + +def test_init_rejects_invalid_session_name() -> None: + with pytest.raises(OPAIValidationError, match="may only contain"): + opai.init("../bad-session") def test_calibrate_writes_artifact(tmp_path, monkeypatch) -> None: @@ -47,6 +95,111 @@ def test_calibrate_writes_artifact(tmp_path, monkeypatch) -> None: assert result.intrinsic_type == "FISHEYE" +def test_add_demos_requires_context(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(context_store, "_ACTIVE_CONTEXT", None) + demo_path = tmp_path / "demo.mp4" + demo_path.write_bytes(b"demo") + + with pytest.raises(OPAIContextError, match="Call opai.init"): + opai.add_demos([demo_path]) + + +def test_add_demos_copies_files_and_preserves_order(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + opai.init("session-001") + demo_a = tmp_path / "demo-a.mp4" + demo_b = tmp_path / "demo-b.mp4" + demo_a.write_bytes(b"a") + demo_b.write_bytes(b"b") + + assets = opai.add_demos([demo_a, demo_b]) + + assert [asset.demo_id for asset in assets] == ["demo-0001", "demo-0002"] + payload = json.loads( + (tmp_path / ".opai_sessions" / "session-001" / "session.json").read_text( + encoding="utf-8" + ) + ) + assert [entry["original_filename"] for entry in payload["demos"]] == [ + "demo-a.mp4", + "demo-b.mp4", + ] + assert ( + tmp_path + / ".opai_sessions" + / "session-001" + / "captures" + / "demos" + / "demo-0001" + / "demo-a.mp4" + ).read_bytes() == b"a" + + +def test_add_mapping_replaces_active_mapping(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + opai.init("session-001") + mapping_a = tmp_path / "mapping-a.mp4" + mapping_b = tmp_path / "mapping-b.mp4" + mapping_a.write_bytes(b"first") + mapping_b.write_bytes(b"second") + + first = opai.add_mapping(mapping_a) + second = opai.add_mapping(mapping_b) + + assert first.original_filename == "mapping-a.mp4" + assert second.original_filename == "mapping-b.mp4" + mapping_dir = ( + tmp_path / ".opai_sessions" / "session-001" / "captures" / "mapping" / "current" + ) + assert sorted(path.name for path in mapping_dir.iterdir()) == ["mapping-b.mp4"] + payload = json.loads( + (tmp_path / ".opai_sessions" / "session-001" / "session.json").read_text( + encoding="utf-8" + ) + ) + assert payload["mapping"]["original_filename"] == "mapping-b.mp4" + + +def test_list_sessions_returns_names(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + opai.init("session-b") + opai.init("session-a") + + assert opai.list_sessions() == ["session-a", "session-b"] + + +def test_browse_session_returns_files_without_changing_active_context( + tmp_path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + ctx = opai.init("session-001") + demo_path = tmp_path / "demo.mp4" + demo_path.write_bytes(b"demo") + opai.add_demos([demo_path]) + _install_fake_rich(monkeypatch) + + files = opai.browse_session("session-001") + + assert "captures/demos/demo-0001/demo.mp4" in files + assert opai.get_context().name == ctx.name + + +def test_browse_session_requires_rich(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + opai.init("session-001") + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name.startswith("rich"): + raise ModuleNotFoundError("No module named 'rich'") + return builtin_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr("builtins.__import__", fake_import) + + with pytest.raises(OPAIDependencyError, match="rich"): + opai.browse_session("session-001") + + def _build_fake_cv2() -> SimpleNamespace: board = SimpleNamespace( getChessboardCorners=lambda: np.array( @@ -97,3 +250,25 @@ def _build_fake_cv2() -> SimpleNamespace: cvtColor=lambda frame, _: frame[:, :, 0], projectPoints=lambda **kwargs: (kwargs["objectPoints"][:, :, :2], None), ) + + +def _install_fake_rich(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeTree: + def __init__(self, label: str) -> None: + self.label = label + self.children = [] + + def add(self, label: str): + child = FakeTree(label) + self.children.append(child) + return child + + class FakeConsole: + def print(self, *_args, **_kwargs) -> None: + return None + + monkeypatch.setitem(sys.modules, "rich", SimpleNamespace()) + monkeypatch.setitem( + sys.modules, "rich.console", SimpleNamespace(Console=FakeConsole) + ) + monkeypatch.setitem(sys.modules, "rich.tree", SimpleNamespace(Tree=FakeTree)) diff --git a/uv.lock b/uv.lock index 11aa648..6034924 100644 --- a/uv.lock +++ b/uv.lock @@ -445,7 +445,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -923,6 +923,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1020,6 +1032,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mistune" version = "3.2.0" @@ -1273,6 +1294,7 @@ source = { editable = "." } dependencies = [ { name = "jupyterlab" }, { name = "opencv-contrib-python" }, + { name = "rich" }, ] [package.optional-dependencies] @@ -1290,6 +1312,7 @@ requires-dist = [ { name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.3.2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.308" }, { name = "pytest", marker = "extra == 'dev'" }, + { name = "rich", specifier = ">=14.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.3" }, ] provides-extras = ["dev"] @@ -1766,6 +1789,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0"