main
py 92 lines 2.97 KB
Raw
1 #!/usr/bin/env python3
2 """Topic-aware data path resolution for SquadScope.
3
4 Provides a single source of truth for data directory layout:
5 data/raw/{topic_id}/
6 data/analyzed/{topic_id}/
7 data/metrics/{topic_id}/
8 data/snapshots/{topic_id}/
9 data/cache/{topic_id}/
10
11 Backward compatibility: when topic_id is None or "general", paths
12 resolve to the legacy flat layout (data/raw/, data/analyzed/, etc.).
13 """
14
15 from __future__ import annotations
16
17 import re
18 from pathlib import Path
19
20 DEFAULT_TOPIC = "general"
21
22 # Base data root (relative to repo root)
23 DATA_ROOT = Path("data")
24
25 # Only allow lowercase alphanumeric, hyphens, and underscores; 1–64 chars.
26 # This prevents path traversal via topic IDs like "../../../etc/passwd".
27 _VALID_TOPIC_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
28
29
30 def _validate_topic_id(tid: str) -> None:
31 """Raise ValueError if tid is not a safe, well-formed topic identifier."""
32 if not _VALID_TOPIC_RE.match(tid):
33 raise ValueError(
34 f"Invalid topic ID: {tid!r}. "
35 "Must be 1–64 lowercase alphanumeric characters, hyphens, or underscores, "
36 "and must not start with a hyphen or underscore."
37 )
38
39
40 def _resolve(base: Path, topic_id: str | None) -> Path:
41 """Return namespaced path, creating parents on first access."""
42 tid = (topic_id or DEFAULT_TOPIC).strip().lower()
43 if tid == DEFAULT_TOPIC:
44 return base
45 _validate_topic_id(tid)
46 return base / tid
47
48
49 def raw_dir(topic_id: str | None = None) -> Path:
50 """Return the raw data directory for a given topic."""
51 return _resolve(DATA_ROOT / "raw", topic_id)
52
53
54 def analyzed_dir(topic_id: str | None = None) -> Path:
55 """Return the analyzed data directory for a given topic."""
56 return _resolve(DATA_ROOT / "analyzed", topic_id)
57
58
59 def metrics_dir(topic_id: str | None = None) -> Path:
60 """Return the metrics directory for a given topic."""
61 return _resolve(DATA_ROOT / "metrics", topic_id)
62
63
64 def snapshots_dir(topic_id: str | None = None) -> Path:
65 """Return the snapshots directory for a given topic."""
66 return _resolve(DATA_ROOT / "snapshots", topic_id)
67
68
69 def cache_dir(topic_id: str | None = None) -> Path:
70 """Return the cache directory for a given topic."""
71 return _resolve(DATA_ROOT / "cache", topic_id)
72
73
74 def ensure_dirs(topic_id: str | None = None) -> None:
75 """Create all data directories for a topic if they don't exist."""
76 for fn in (raw_dir, analyzed_dir, metrics_dir, snapshots_dir, cache_dir):
77 fn(topic_id).mkdir(parents=True, exist_ok=True)
78
79
80 def load_topic_id(config_path: str | Path = "squadscope.topic.yml") -> str:
81 """Load topic.id from a YAML config file. Returns DEFAULT_TOPIC on failure."""
82 import yaml
83
84 path = Path(config_path)
85 if not path.exists():
86 return DEFAULT_TOPIC
87 try:
88 with open(path, encoding="utf-8") as f:
89 data = yaml.safe_load(f)
90 return (data or {}).get("topic", {}).get("id", DEFAULT_TOPIC)
91 except Exception:
92 return DEFAULT_TOPIC