chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""Utility modules for CUA CLI."""
|
||||
|
||||
from .async_utils import run_async
|
||||
from .output import (
|
||||
print_error,
|
||||
print_info,
|
||||
print_json,
|
||||
print_success,
|
||||
print_table,
|
||||
print_warning,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"print_table",
|
||||
"print_json",
|
||||
"print_error",
|
||||
"print_success",
|
||||
"print_warning",
|
||||
"print_info",
|
||||
"run_async",
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Async utilities for CUA CLI."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Coroutine, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def run_async(coro: Coroutine[Any, Any, T]) -> T:
|
||||
"""Run an async coroutine synchronously.
|
||||
|
||||
This is the standard pattern for CLI commands that need to call async code.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run
|
||||
|
||||
Returns:
|
||||
The result of the coroutine
|
||||
"""
|
||||
return asyncio.run(coro)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Docker utilities for CUA CLI."""
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_free_port(start: int = 5000, end: int = 9000) -> int:
|
||||
"""Find a free port in the given range.
|
||||
|
||||
Args:
|
||||
start: Start of port range (inclusive)
|
||||
end: End of port range (exclusive)
|
||||
|
||||
Returns:
|
||||
First available port in the range
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no free port found
|
||||
"""
|
||||
import socket
|
||||
|
||||
for port in range(start, end):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
|
||||
|
||||
def allocate_ports(vnc_default: int = 8006, api_default: int = 5000) -> tuple[int, int]:
|
||||
"""Allocate VNC and API ports, auto-selecting if defaults are in use.
|
||||
|
||||
Args:
|
||||
vnc_default: Preferred VNC port
|
||||
api_default: Preferred API port
|
||||
|
||||
Returns:
|
||||
Tuple of (vnc_port, api_port)
|
||||
"""
|
||||
import socket
|
||||
|
||||
def is_port_free(port: int) -> bool:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
vnc_port = vnc_default if is_port_free(vnc_default) else find_free_port(8000, 9000)
|
||||
api_port = api_default if is_port_free(api_default) else find_free_port(5000, 6000)
|
||||
|
||||
return vnc_port, api_port
|
||||
|
||||
|
||||
def create_overlay_copy(golden_path: Path, overlay_path: Path, verbose: bool = False) -> None:
|
||||
"""Copy golden image to overlay directory for COW-like behavior.
|
||||
|
||||
This is a temporary workaround until proper QEMU overlay support is added.
|
||||
Uses native `cp -a` on Unix for speed (5x faster than Python shutil).
|
||||
Falls back to shutil.copytree on Windows.
|
||||
|
||||
WIP: https://github.com/trycua/cua/issues/699
|
||||
|
||||
Args:
|
||||
golden_path: Path to golden image directory
|
||||
overlay_path: Path to overlay directory (will be created/cleaned)
|
||||
verbose: Print progress messages
|
||||
|
||||
Raises:
|
||||
RuntimeError: If copy fails
|
||||
"""
|
||||
if overlay_path.exists():
|
||||
shutil.rmtree(overlay_path)
|
||||
overlay_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if verbose:
|
||||
print(f" Source: {golden_path}")
|
||||
print(f" Overlay: {overlay_path}")
|
||||
print(" (This may take a while for large images)")
|
||||
print(" WIP: https://github.com/trycua/cua/issues/699")
|
||||
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
for item in golden_path.iterdir():
|
||||
src = golden_path / item.name
|
||||
dst = overlay_path / item.name
|
||||
if src.is_dir():
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to create overlay: {e}")
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["cp", "-a", f"{golden_path}/.", str(overlay_path)], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create overlay: {result.stderr or 'cp failed'}")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Output formatting utilities for CUA CLI."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
error_console = Console(stderr=True)
|
||||
|
||||
|
||||
def print_table(
|
||||
data: list[dict[str, Any]],
|
||||
columns: list[tuple[str, str]] | None = None,
|
||||
title: str | None = None,
|
||||
) -> None:
|
||||
"""Print data as a formatted table.
|
||||
|
||||
Args:
|
||||
data: List of dictionaries to display
|
||||
columns: List of (key, header) tuples. If None, uses all keys from first item.
|
||||
title: Optional table title
|
||||
"""
|
||||
if not data:
|
||||
console.print("[dim]No data to display[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title=title, show_header=True, header_style="bold")
|
||||
|
||||
if columns is None:
|
||||
columns = [(k, k.upper()) for k in data[0].keys()]
|
||||
|
||||
for _, header in columns:
|
||||
table.add_column(header)
|
||||
|
||||
for item in data:
|
||||
row = [str(item.get(key, "")) for key, _ in columns]
|
||||
table.add_row(*row)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_json(data: Any) -> None:
|
||||
"""Print data as formatted JSON."""
|
||||
console.print_json(json.dumps(data, indent=2, default=str))
|
||||
|
||||
|
||||
def print_error(message: str) -> None:
|
||||
"""Print an error message to stderr."""
|
||||
error_console.print(f"[red]Error:[/red] {message}")
|
||||
|
||||
|
||||
def print_success(message: str) -> None:
|
||||
"""Print a success message."""
|
||||
console.print(f"[green]{message}[/green]")
|
||||
|
||||
|
||||
def print_warning(message: str) -> None:
|
||||
"""Print a warning message."""
|
||||
console.print(f"[yellow]Warning:[/yellow] {message}")
|
||||
|
||||
|
||||
def print_info(message: str) -> None:
|
||||
"""Print an info message."""
|
||||
console.print(f"[blue]{message}[/blue]")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""XDG Base Directory helpers for CUA CLI."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_xdg_data_home() -> Path:
|
||||
"""Get XDG_DATA_HOME, defaulting to ~/.local/share per spec."""
|
||||
xdg_data = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg_data:
|
||||
return Path(xdg_data)
|
||||
return Path.home() / ".local" / "share"
|
||||
|
||||
|
||||
def get_xdg_state_home() -> Path:
|
||||
"""Get XDG_STATE_HOME, defaulting to ~/.local/state per spec."""
|
||||
xdg_state = os.environ.get("XDG_STATE_HOME")
|
||||
if xdg_state:
|
||||
return Path(xdg_state)
|
||||
return Path.home() / ".local" / "state"
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Get the CUA data directory (for images)."""
|
||||
return get_xdg_data_home() / "cua"
|
||||
|
||||
|
||||
def get_state_dir() -> Path:
|
||||
"""Get the CUA state directory (for registries)."""
|
||||
return get_xdg_state_home() / "cua"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Image registry for CUA CLI.
|
||||
|
||||
Manages a JSON registry of locally stored images at ~/.local/state/cua/images.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cua_cli.utils.paths import get_data_dir, get_state_dir
|
||||
|
||||
|
||||
def get_image_registry_path() -> Path:
|
||||
"""Get the image registry file path."""
|
||||
return get_state_dir() / "images.json"
|
||||
|
||||
|
||||
def load_image_registry() -> Dict[str, Any]:
|
||||
"""Load the image registry."""
|
||||
registry_path = get_image_registry_path()
|
||||
if registry_path.exists():
|
||||
try:
|
||||
data = json.loads(registry_path.read_text())
|
||||
return data.get("images", data)
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_image_registry(registry: Dict[str, Any]) -> None:
|
||||
"""Save the image registry."""
|
||||
registry_path = get_image_registry_path()
|
||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {"version": 1, "images": registry}
|
||||
registry_path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def register_image(
|
||||
name: str,
|
||||
platform: str,
|
||||
path: Path,
|
||||
description: str = "",
|
||||
docker_image: Optional[str] = None,
|
||||
config: Optional[Dict] = None,
|
||||
parent: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
apps_installed: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Register an image in the registry."""
|
||||
from cua_cli.commands.platform import PLATFORMS
|
||||
|
||||
registry = load_image_registry()
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"platform": platform,
|
||||
"path": str(path),
|
||||
"description": description,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
if docker_image:
|
||||
entry["docker_image"] = docker_image
|
||||
if config:
|
||||
entry["config"] = config
|
||||
if parent:
|
||||
entry["parent"] = parent
|
||||
if tags:
|
||||
entry["tags"] = tags
|
||||
if apps_installed:
|
||||
entry["apps_installed"] = apps_installed
|
||||
|
||||
platform_config = PLATFORMS.get(platform, {})
|
||||
if platform_config.get("image_marker"):
|
||||
entry["marker_file"] = platform_config["image_marker"]
|
||||
|
||||
registry[name] = entry
|
||||
save_image_registry(registry)
|
||||
|
||||
|
||||
def unregister_image(name: str) -> None:
|
||||
"""Remove an image from the registry."""
|
||||
registry = load_image_registry()
|
||||
if name in registry:
|
||||
del registry[name]
|
||||
save_image_registry(registry)
|
||||
|
||||
|
||||
def get_image_info(name: str) -> Optional[Dict]:
|
||||
"""Get image info by name."""
|
||||
registry = load_image_registry()
|
||||
return registry.get(name)
|
||||
|
||||
|
||||
def list_images() -> Dict[str, Any]:
|
||||
"""List all registered images."""
|
||||
return load_image_registry()
|
||||
|
||||
|
||||
def auto_discover_images() -> None:
|
||||
"""Scan images directory and register any unregistered images.
|
||||
|
||||
Handles images created with --detach mode that never got registered.
|
||||
"""
|
||||
from cua_cli.commands.platform import PLATFORMS
|
||||
|
||||
images_dir = get_data_dir() / "images"
|
||||
if not images_dir.exists():
|
||||
return
|
||||
|
||||
registry = load_image_registry()
|
||||
modified = False
|
||||
|
||||
for image_dir in images_dir.iterdir():
|
||||
if not image_dir.is_dir():
|
||||
continue
|
||||
|
||||
name = image_dir.name
|
||||
|
||||
if name in registry:
|
||||
continue
|
||||
|
||||
for platform_name, config in PLATFORMS.items():
|
||||
marker = config.get("image_marker")
|
||||
if not marker:
|
||||
continue
|
||||
|
||||
marker_path = image_dir / marker
|
||||
if marker_path.exists():
|
||||
print(f"Auto-registering discovered image: {name} ({platform_name})")
|
||||
|
||||
entry = {
|
||||
"platform": platform_name,
|
||||
"path": str(image_dir),
|
||||
"description": f"Auto-discovered {platform_name} image",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"docker_image": config.get("image"),
|
||||
"config": {"memory": "8G", "cpus": "8"},
|
||||
"tags": ["auto-discovered"],
|
||||
"marker_file": marker,
|
||||
}
|
||||
|
||||
registry[name] = entry
|
||||
modified = True
|
||||
break
|
||||
|
||||
if modified:
|
||||
save_image_registry(registry)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Trajectory recording utility for cua do actions.
|
||||
|
||||
Records each action into a replayable trajectory compatible with the
|
||||
TrajectoryViewer at cua.ai/trajectory-viewer.
|
||||
|
||||
All state is file-based (each `cua do` invocation is a separate process).
|
||||
The current session path is stored in ~/.cua/do_target.json under the
|
||||
``trajectory_session`` key.
|
||||
|
||||
Directory layout::
|
||||
|
||||
~/.cua/trajectories/{machine_name}/{YYYYMMDD-HHMMSS}/
|
||||
turn_001/
|
||||
screenshot.png
|
||||
turn_001_agent_response.json
|
||||
turn_002/
|
||||
screenshot.png
|
||||
turn_002_agent_response.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_CUA_DIR = Path.home() / ".cua"
|
||||
_TRAJECTORIES_DIR = _CUA_DIR / "trajectories"
|
||||
_STATE_FILE = _CUA_DIR / "do_target.json"
|
||||
|
||||
|
||||
# ── state helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
if _STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(_STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state: dict) -> None:
|
||||
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
# ── session management ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def ensure_session(state: dict) -> Path:
|
||||
"""Return the current session directory, creating one if needed.
|
||||
|
||||
If ``trajectory_session`` is already set in *state* and the directory
|
||||
exists, it is reused. Otherwise a new timestamped directory is created
|
||||
under ``~/.cua/trajectories/{machine_name}/`` and persisted to state.
|
||||
"""
|
||||
existing = state.get("trajectory_session")
|
||||
if existing:
|
||||
p = Path(existing)
|
||||
if p.is_dir():
|
||||
return p
|
||||
|
||||
machine = state.get("name") or state.get("provider") or "unknown"
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
session_dir = _TRAJECTORIES_DIR / machine / ts
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state["trajectory_session"] = str(session_dir)
|
||||
_save_state(state)
|
||||
return session_dir
|
||||
|
||||
|
||||
def reset_session(state: dict) -> None:
|
||||
"""Clear ``trajectory_session`` from state (called on ``cua do switch``)."""
|
||||
state.pop("trajectory_session", None)
|
||||
_save_state(state)
|
||||
|
||||
|
||||
# ── turn recording ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_next_turn_number(session_dir: Path) -> int:
|
||||
"""Scan for existing ``turn_NNN`` dirs and return the next number."""
|
||||
max_n = 0
|
||||
for child in session_dir.iterdir():
|
||||
if child.is_dir() and child.name.startswith("turn_"):
|
||||
try:
|
||||
n = int(child.name.split("_", 1)[1])
|
||||
if n > max_n:
|
||||
max_n = n
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return max_n + 1
|
||||
|
||||
|
||||
def _build_action_dict(action_type: str, action_params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the ``action`` dict for the agent-response JSON."""
|
||||
action: dict[str, Any] = {"type": action_type}
|
||||
action.update(action_params)
|
||||
return action
|
||||
|
||||
|
||||
def record_turn(
|
||||
session_dir: Path,
|
||||
action_type: str,
|
||||
action_params: dict[str, Any],
|
||||
screenshot_bytes: bytes | None = None,
|
||||
) -> Path:
|
||||
"""Write a single turn to the session directory.
|
||||
|
||||
Creates ``turn_NNN/screenshot.png`` (if *screenshot_bytes* provided)
|
||||
and ``turn_NNN/turn_NNN_agent_response.json``.
|
||||
|
||||
Returns the turn directory.
|
||||
"""
|
||||
turn_num = get_next_turn_number(session_dir)
|
||||
turn_name = f"turn_{turn_num:03d}"
|
||||
turn_dir = session_dir / turn_name
|
||||
turn_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Screenshot
|
||||
if screenshot_bytes:
|
||||
(turn_dir / "screenshot.png").write_bytes(screenshot_bytes)
|
||||
|
||||
# Agent response JSON (TrajectoryViewer-compatible)
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
ts_ms = int(time.time())
|
||||
|
||||
response_json: dict[str, Any] = {
|
||||
"model": "cua-cli",
|
||||
"response": {
|
||||
"id": f"resp_{int(time.time() * 1000)}",
|
||||
"object": "response",
|
||||
"created_at": ts_ms,
|
||||
"status": "completed",
|
||||
"model": "cua-cli",
|
||||
"output": [
|
||||
{
|
||||
"type": "computer_call",
|
||||
"id": call_id,
|
||||
"call_id": call_id,
|
||||
"action": _build_action_dict(action_type, action_params),
|
||||
"pending_safety_checks": [],
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
json_path = turn_dir / f"{turn_name}_agent_response.json"
|
||||
json_path.write_text(json.dumps(response_json, indent=2))
|
||||
|
||||
return turn_dir
|
||||
|
||||
|
||||
# ── listing / inspection ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def list_trajectories(machine: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of trajectory sessions.
|
||||
|
||||
Each entry is ``{machine, session, path, turns, created}``.
|
||||
If *machine* is given, only that machine's sessions are returned.
|
||||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
if not _TRAJECTORIES_DIR.is_dir():
|
||||
return results
|
||||
|
||||
machines = [_TRAJECTORIES_DIR / machine] if machine else sorted(_TRAJECTORIES_DIR.iterdir())
|
||||
|
||||
for machine_dir in machines:
|
||||
if not machine_dir.is_dir():
|
||||
continue
|
||||
for session_dir in sorted(machine_dir.iterdir()):
|
||||
if not session_dir.is_dir():
|
||||
continue
|
||||
turns = sum(
|
||||
1 for c in session_dir.iterdir() if c.is_dir() and c.name.startswith("turn_")
|
||||
)
|
||||
# Parse created timestamp from dir name
|
||||
try:
|
||||
created = datetime.strptime(session_dir.name, "%Y%m%d-%H%M%S")
|
||||
except ValueError:
|
||||
created = datetime.fromtimestamp(session_dir.stat().st_ctime)
|
||||
results.append(
|
||||
{
|
||||
"machine": machine_dir.name,
|
||||
"session": session_dir.name,
|
||||
"path": str(session_dir),
|
||||
"turns": turns,
|
||||
"created": created.isoformat(),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
# ── zipping ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def zip_trajectory(session_path: str | Path) -> Path:
|
||||
"""Create a zip of a session directory in TrajectoryViewer-compatible format.
|
||||
|
||||
Returns the path to the created zip file (placed next to the session dir).
|
||||
"""
|
||||
session_dir = Path(session_path)
|
||||
zip_path = session_dir.parent / f"{session_dir.name}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for child in sorted(session_dir.rglob("*")):
|
||||
if child.is_file():
|
||||
zf.write(child, child.relative_to(session_dir))
|
||||
return zip_path
|
||||
|
||||
|
||||
# ── cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def clean_trajectories(
|
||||
older_than_days: int | None = None,
|
||||
machine: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Delete trajectory sessions matching the criteria.
|
||||
|
||||
Returns a list of deleted session paths.
|
||||
"""
|
||||
deleted: list[str] = []
|
||||
if not _TRAJECTORIES_DIR.is_dir():
|
||||
return deleted
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=older_than_days) if older_than_days else None
|
||||
machines = [_TRAJECTORIES_DIR / machine] if machine else sorted(_TRAJECTORIES_DIR.iterdir())
|
||||
|
||||
for machine_dir in machines:
|
||||
if not machine_dir.is_dir():
|
||||
continue
|
||||
for session_dir in sorted(machine_dir.iterdir()):
|
||||
if not session_dir.is_dir():
|
||||
continue
|
||||
if cutoff:
|
||||
try:
|
||||
created = datetime.strptime(session_dir.name, "%Y%m%d-%H%M%S")
|
||||
except ValueError:
|
||||
created = datetime.fromtimestamp(session_dir.stat().st_ctime)
|
||||
if created >= cutoff:
|
||||
continue
|
||||
shutil.rmtree(session_dir)
|
||||
deleted.append(str(session_dir))
|
||||
# Also remove zip if it exists
|
||||
zip_path = session_dir.parent / f"{session_dir.name}.zip"
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
# Remove empty machine dirs
|
||||
if machine_dir.is_dir() and not any(machine_dir.iterdir()):
|
||||
machine_dir.rmdir()
|
||||
|
||||
return deleted
|
||||
Reference in New Issue
Block a user