Ports Minas's PR #172 (against pre-rename `da` CLI on main) and applies the principle to the post-rename `agnes` CLI. Two distinct failure modes on Windows consoles whose default codepage is cp1250 (cs-CZ) / cp1252 (en-US): 1. `agnes pull` and other Rich-progress codepaths UnicodeEncodeError on Braille spinner glyphs. Fix: `cli/main.py` reconfigures stdout/stderr to UTF-8 with errors='replace' at import time on `sys.platform == 'win32'` so Rich's legacy-Windows render path emits decodable bytes. Wrapped in try/except so pytest's captured streams (which aren't TextIOWrapper) don't break. 2. `agnes skills list` and `agnes skills show` UnicodeDecodeError when reading skill markdown containing em-dashes / accented chars. Default `Path.read_text()` uses locale.getpreferredencoding(False), which is the broken codepage on Windows. Fix: every call site passes encoding='utf-8' explicitly. Broader scope than #172 because: - The bootstrap rewrite renamed/removed several files Minas's PR patched (`cli/commands/analyst.py` -> rolled into init.py; `cli/commands/sync.py` -> split into pull/push). Those targets no longer exist; the equivalent code lives in init.py. - Other call sites Minas didn't touch (still bare in his branch) are patched here too — config.py / update_check.py / snapshot_meta.py / setup.py / skills.py — so the codebase has zero locale-default text I/O in cli/. Side cleanup: stale `Run `da`` reference in snapshot_meta.py:88 fixed to `agnes` while touching the file.
99 lines
3.1 KiB
Python
99 lines
3.1 KiB
Python
"""Snapshot sidecar metadata + file lock helpers (spec §4.2)."""
|
|
|
|
from __future__ import annotations
|
|
import contextlib
|
|
import json
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
# `fcntl` is POSIX-only. The CLI is primarily targeted at Mac/Linux laptops, but
|
|
# import-time failure on Windows would make the whole module (incl. read_meta /
|
|
# list_snapshots) unusable. Make the import lazy so non-locking helpers still
|
|
# work; `snapshot_lock` raises a clear error if anything tries to acquire it.
|
|
try:
|
|
import fcntl as _fcntl # type: ignore[import-not-found]
|
|
except ImportError: # pragma: no cover — exercised only on Windows
|
|
_fcntl = None # type: ignore[assignment]
|
|
|
|
|
|
@dataclass
|
|
class SnapshotMeta:
|
|
name: str
|
|
table_id: str
|
|
select: Optional[list[str]]
|
|
where: Optional[str]
|
|
limit: Optional[int]
|
|
order_by: Optional[list[str]]
|
|
fetched_at: str # ISO 8601 UTC
|
|
effective_as_of: str # ISO 8601 UTC, server-side eval time
|
|
rows: int
|
|
bytes_local: int
|
|
estimated_scan_bytes_at_fetch: int
|
|
result_hash_md5: str
|
|
|
|
|
|
def _meta_path(snap_dir: Path, name: str) -> Path:
|
|
return snap_dir / f"{name}.meta.json"
|
|
|
|
|
|
def write_meta(snap_dir: Path, meta: SnapshotMeta) -> None:
|
|
snap_dir.mkdir(parents=True, exist_ok=True)
|
|
with _meta_path(snap_dir, meta.name).open("w", encoding="utf-8") as f:
|
|
json.dump(asdict(meta), f, indent=2)
|
|
|
|
|
|
def read_meta(snap_dir: Path, name: str) -> Optional[SnapshotMeta]:
|
|
p = _meta_path(snap_dir, name)
|
|
if not p.exists():
|
|
return None
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
return SnapshotMeta(**data)
|
|
|
|
|
|
def list_snapshots(snap_dir: Path) -> list[SnapshotMeta]:
|
|
if not snap_dir.exists():
|
|
return []
|
|
out = []
|
|
for meta_file in snap_dir.glob("*.meta.json"):
|
|
try:
|
|
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
out.append(SnapshotMeta(**data))
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
return out
|
|
|
|
|
|
def delete_snapshot(snap_dir: Path, name: str) -> bool:
|
|
"""Delete the snapshot's parquet + meta. Returns True if removed, False if missing."""
|
|
parquet = snap_dir / f"{name}.parquet"
|
|
meta = _meta_path(snap_dir, name)
|
|
removed = False
|
|
if parquet.exists():
|
|
parquet.unlink(); removed = True
|
|
if meta.exists():
|
|
meta.unlink(); removed = True
|
|
return removed
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def snapshot_lock(snap_dir: Path):
|
|
"""Exclusive flock on snap_dir/.lock — serializes snapshot installs.
|
|
|
|
Concurrent `da fetch` invocations queue here.
|
|
"""
|
|
if _fcntl is None:
|
|
raise RuntimeError(
|
|
"snapshot_lock requires POSIX fcntl — Windows is not supported. "
|
|
"Run `agnes` from a Mac or Linux machine, or use a WSL shell."
|
|
)
|
|
snap_dir.mkdir(parents=True, exist_ok=True)
|
|
lock_file = snap_dir / ".lock"
|
|
lock_file.touch(exist_ok=True)
|
|
fd = open(lock_file, "r+")
|
|
try:
|
|
_fcntl.flock(fd.fileno(), _fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
_fcntl.flock(fd.fileno(), _fcntl.LOCK_UN)
|
|
fd.close()
|