siftcode 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- siftcode/__init__.py +3 -0
- siftcode/__main__.py +11 -0
- siftcode/_version.py +59 -0
- siftcode/cli.py +531 -0
- siftcode/core/__init__.py +1 -0
- siftcode/core/analyzer.py +575 -0
- siftcode/core/cache.py +300 -0
- siftcode/core/config.py +258 -0
- siftcode/core/llm.py +295 -0
- siftcode/core/manual.py +208 -0
- siftcode/core/prompts.py +215 -0
- siftcode/data/__init__.py +0 -0
- siftcode/data/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +9 -0
- siftcode/gui/analyzer_view.py +560 -0
- siftcode/gui/app.py +252 -0
- siftcode/gui/guide_window.py +88 -0
- siftcode/gui/project_list.py +160 -0
- siftcode/gui/settings_view.py +430 -0
- siftcode/gui/widgets/__init__.py +1 -0
- siftcode/gui/widgets/exclusion_list.py +169 -0
- siftcode/gui/widgets/log_view.py +108 -0
- siftcode/gui/widgets/markdown_view.py +205 -0
- siftcode/gui/widgets/project_row.py +141 -0
- siftcode/gui/window.py +446 -0
- siftcode-0.3.0.dist-info/METADATA +413 -0
- siftcode-0.3.0.dist-info/RECORD +31 -0
- siftcode-0.3.0.dist-info/WHEEL +5 -0
- siftcode-0.3.0.dist-info/entry_points.txt +5 -0
- siftcode-0.3.0.dist-info/licenses/LICENSE +674 -0
- siftcode-0.3.0.dist-info/top_level.txt +1 -0
siftcode/core/cache.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hash-based cache for Siftcode analysis results.
|
|
3
|
+
|
|
4
|
+
Caches LLM responses keyed by file content hash and model name.
|
|
5
|
+
This means:
|
|
6
|
+
- Unchanged files are never re-analyzed
|
|
7
|
+
- Switching models invalidates the cache (different model = different result)
|
|
8
|
+
- Cache entries never expire on their own — run `siftcode cache gc` to prune
|
|
9
|
+
|
|
10
|
+
No GTK dependencies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from platformdirs import user_cache_dir
|
|
24
|
+
|
|
25
|
+
from siftcode.core.config import APP_AUTHOR, APP_NAME
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Default location
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
CACHE_DIR = Path(user_cache_dir(APP_NAME, APP_AUTHOR))
|
|
36
|
+
FILES_CACHE_DIR = CACHE_DIR / "files"
|
|
37
|
+
DIRS_CACHE_DIR = CACHE_DIR / "dirs"
|
|
38
|
+
ROOT_CACHE_DIR = CACHE_DIR / "roots"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Hash helpers
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def file_hash(path: Path) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Compute a stable SHA-256 hash of a file's content.
|
|
48
|
+
|
|
49
|
+
Reads in 8 KB chunks so large files don't blow up memory.
|
|
50
|
+
Returns the first 16 hex characters — collision-free in practice.
|
|
51
|
+
"""
|
|
52
|
+
h = hashlib.sha256()
|
|
53
|
+
with open(path, "rb") as f:
|
|
54
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
55
|
+
h.update(chunk)
|
|
56
|
+
return h.hexdigest()[:16]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def string_hash(text: str) -> str:
|
|
60
|
+
"""Hash a string (used for directory and root keys)."""
|
|
61
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Cache entry
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CacheEntry:
|
|
70
|
+
"""A single cached analysis result."""
|
|
71
|
+
|
|
72
|
+
key: str # hash of the input (file content, dir listing, etc.)
|
|
73
|
+
path: str # project-relative path or label
|
|
74
|
+
model: str # model name — cache is model-specific
|
|
75
|
+
summary: str # the LLM's response
|
|
76
|
+
analyzed_at: str # ISO timestamp
|
|
77
|
+
last_used: str # ISO timestamp — updated on every hit
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def age_days(self) -> float:
|
|
81
|
+
"""Days since this entry was last used."""
|
|
82
|
+
last = time.mktime(time.strptime(self.last_used[:19], "%Y-%m-%dT%H:%M:%S"))
|
|
83
|
+
return (time.time() - last) / 86400.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Cache
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
class AnalysisCache:
|
|
91
|
+
"""
|
|
92
|
+
File-backed cache for LLM analysis results.
|
|
93
|
+
|
|
94
|
+
Layout:
|
|
95
|
+
~/.cache/siftcode/files/<hash>.json
|
|
96
|
+
~/.cache/siftcode/dirs/<hash>.json
|
|
97
|
+
~/.cache/siftcode/roots/<hash>.json
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
base_dir: Path | None = None,
|
|
103
|
+
enabled: bool = True,
|
|
104
|
+
) -> None:
|
|
105
|
+
self.base_dir = base_dir or CACHE_DIR
|
|
106
|
+
self.enabled = enabled
|
|
107
|
+
|
|
108
|
+
self.files_dir = self.base_dir / "files"
|
|
109
|
+
self.dirs_dir = self.base_dir / "dirs"
|
|
110
|
+
self.roots_dir = self.base_dir / "roots"
|
|
111
|
+
|
|
112
|
+
for d in (self.files_dir, self.dirs_dir, self.roots_dir):
|
|
113
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Public API — file-level
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def get_file(self, key: str, model: str) -> str | None:
|
|
120
|
+
"""Return cached file summary, or None on miss."""
|
|
121
|
+
return self._get(self.files_dir, key, model)
|
|
122
|
+
|
|
123
|
+
def put_file(self, key: str, path: str, model: str, summary: str) -> None:
|
|
124
|
+
"""Store a file summary in the cache."""
|
|
125
|
+
self._put(self.files_dir, key, path, model, summary)
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Public API — directory-level
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def get_directory(self, key: str, model: str) -> str | None:
|
|
132
|
+
return self._get(self.dirs_dir, key, model)
|
|
133
|
+
|
|
134
|
+
def put_directory(self, key: str, path: str, model: str, summary: str) -> None:
|
|
135
|
+
self._put(self.dirs_dir, key, path, model, summary)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Public API — root-level
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def get_root(self, key: str, model: str) -> str | None:
|
|
142
|
+
return self._get(self.roots_dir, key, model)
|
|
143
|
+
|
|
144
|
+
def put_root(self, key: str, path: str, model: str, summary: str) -> None:
|
|
145
|
+
self._put(self.roots_dir, key, path, model, summary)
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Public API — stats / maintenance
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def stats(self) -> dict[str, Any]:
|
|
152
|
+
"""Return counts and total size per cache category."""
|
|
153
|
+
result: dict[str, Any] = {}
|
|
154
|
+
total_entries = 0
|
|
155
|
+
total_bytes = 0
|
|
156
|
+
|
|
157
|
+
for name, d in (
|
|
158
|
+
("files", self.files_dir),
|
|
159
|
+
("directories", self.dirs_dir),
|
|
160
|
+
("roots", self.roots_dir),
|
|
161
|
+
):
|
|
162
|
+
count = 0
|
|
163
|
+
size = 0
|
|
164
|
+
for f in d.glob("*.json"):
|
|
165
|
+
count += 1
|
|
166
|
+
size += f.stat().st_size
|
|
167
|
+
result[name] = {"entries": count, "bytes": size}
|
|
168
|
+
total_entries += count
|
|
169
|
+
total_bytes += size
|
|
170
|
+
|
|
171
|
+
result["total"] = {
|
|
172
|
+
"entries": total_entries,
|
|
173
|
+
"bytes": total_bytes,
|
|
174
|
+
"human_size": _human_size(total_bytes),
|
|
175
|
+
}
|
|
176
|
+
result["base_dir"] = str(self.base_dir)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def clear(self, model: str | None = None) -> int:
|
|
180
|
+
"""
|
|
181
|
+
Remove cache entries.
|
|
182
|
+
|
|
183
|
+
If model is None, clears everything. Otherwise only entries for
|
|
184
|
+
that model. Returns the number of entries removed.
|
|
185
|
+
"""
|
|
186
|
+
removed = 0
|
|
187
|
+
for d in (self.files_dir, self.dirs_dir, self.roots_dir):
|
|
188
|
+
for f in list(d.glob("*.json")):
|
|
189
|
+
if model is None:
|
|
190
|
+
f.unlink()
|
|
191
|
+
removed += 1
|
|
192
|
+
else:
|
|
193
|
+
try:
|
|
194
|
+
data = json.loads(f.read_text())
|
|
195
|
+
if data.get("model") == model:
|
|
196
|
+
f.unlink()
|
|
197
|
+
removed += 1
|
|
198
|
+
except (OSError, json.JSONDecodeError):
|
|
199
|
+
# Corrupt file — remove it anyway
|
|
200
|
+
f.unlink()
|
|
201
|
+
removed += 1
|
|
202
|
+
return removed
|
|
203
|
+
|
|
204
|
+
def gc(self, max_age_days: float = 30.0) -> int:
|
|
205
|
+
"""
|
|
206
|
+
Delete cache entries not used within max_age_days.
|
|
207
|
+
|
|
208
|
+
Returns the number of entries removed.
|
|
209
|
+
"""
|
|
210
|
+
removed = 0
|
|
211
|
+
for d in (self.files_dir, self.dirs_dir, self.roots_dir):
|
|
212
|
+
for f in d.glob("*.json"):
|
|
213
|
+
try:
|
|
214
|
+
data = json.loads(f.read_text())
|
|
215
|
+
entry = CacheEntry(**data)
|
|
216
|
+
if entry.age_days > max_age_days:
|
|
217
|
+
f.unlink()
|
|
218
|
+
removed += 1
|
|
219
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
220
|
+
# Corrupt or missing fields — treat as garbage
|
|
221
|
+
f.unlink()
|
|
222
|
+
removed += 1
|
|
223
|
+
return removed
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Internals
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _get(self, directory: Path, key: str, model: str) -> str | None:
|
|
230
|
+
if not self.enabled:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
path = directory / f"{key}.json"
|
|
234
|
+
if not path.exists():
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(path.read_text())
|
|
239
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
240
|
+
logger.warning(f"Corrupt cache entry {path}: {e}")
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
# Model must match
|
|
244
|
+
if data.get("model") != model:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
# Update last_used timestamp (fire-and-forget)
|
|
248
|
+
try:
|
|
249
|
+
data["last_used"] = _now_iso()
|
|
250
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
251
|
+
except OSError:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
return data.get("summary")
|
|
255
|
+
|
|
256
|
+
def _put(
|
|
257
|
+
self,
|
|
258
|
+
directory: Path,
|
|
259
|
+
key: str,
|
|
260
|
+
path_str: str,
|
|
261
|
+
model: str,
|
|
262
|
+
summary: str,
|
|
263
|
+
) -> None:
|
|
264
|
+
if not self.enabled:
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
entry = CacheEntry(
|
|
268
|
+
key=key,
|
|
269
|
+
path=path_str,
|
|
270
|
+
model=model,
|
|
271
|
+
summary=summary,
|
|
272
|
+
analyzed_at=_now_iso(),
|
|
273
|
+
last_used=_now_iso(),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
target = directory / f"{key}.json"
|
|
277
|
+
try:
|
|
278
|
+
target.write_text(
|
|
279
|
+
json.dumps(asdict(entry), indent=2, ensure_ascii=False),
|
|
280
|
+
encoding="utf-8",
|
|
281
|
+
)
|
|
282
|
+
except OSError as e:
|
|
283
|
+
logger.warning(f"Cannot write cache entry {target}: {e}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Helpers
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _now_iso() -> str:
|
|
291
|
+
from datetime import datetime
|
|
292
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _human_size(num_bytes: int) -> str:
|
|
296
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
297
|
+
if num_bytes < 1024:
|
|
298
|
+
return f"{num_bytes:.1f} {unit}"
|
|
299
|
+
num_bytes /= 1024
|
|
300
|
+
return f"{num_bytes:.1f} TB"
|
siftcode/core/config.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Siftcode.
|
|
3
|
+
|
|
4
|
+
Handles loading, saving and validating project configuration files.
|
|
5
|
+
Uses XDG base directory specification via platformdirs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import tomli_w
|
|
17
|
+
from platformdirs import user_config_dir, user_cache_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# XDG-compliant directories
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
APP_NAME = "siftcode"
|
|
25
|
+
APP_AUTHOR = "siftcode"
|
|
26
|
+
|
|
27
|
+
CONFIG_DIR = Path(user_config_dir(APP_NAME, APP_AUTHOR))
|
|
28
|
+
PROJECTS_DIR = CONFIG_DIR / "projects"
|
|
29
|
+
CACHE_DIR = Path(user_cache_dir(APP_NAME, APP_AUTHOR))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Default exclusion list — used if user hasn't customized it
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
DEFAULT_EXCLUSIONS = [
|
|
37
|
+
# Version control / environment
|
|
38
|
+
".git", ".venv", "venv", "env", "node_modules", "__pycache__",
|
|
39
|
+
# Version control metadata (not source code)
|
|
40
|
+
".gitignore", ".gitattributes", ".gitmodules", ".gitkeep",
|
|
41
|
+
# Build artifacts
|
|
42
|
+
"build", "dist", ".eggs", ".tox", ".pytest_cache", ".mypy_cache",
|
|
43
|
+
".ruff_cache", ".coverage", ".htmlcov", "htmlcov",
|
|
44
|
+
# Documentation
|
|
45
|
+
"docs", "doc", "documentation", "README.md~", "docs/specs/", "doc/specs/",
|
|
46
|
+
# Editor backup files
|
|
47
|
+
"*~", "*.bak", "*.swp", "*.swo",
|
|
48
|
+
# Binary / media files
|
|
49
|
+
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
|
|
50
|
+
".pdf", ".zip", ".tar", ".gz", ".whl", ".so", ".dll", ".exe",
|
|
51
|
+
".ttf", ".otf", ".woff", ".woff2",
|
|
52
|
+
".pyc", ".pyo",
|
|
53
|
+
# Lock files
|
|
54
|
+
".lock",
|
|
55
|
+
# Coverage data
|
|
56
|
+
".coverage", ".coverage.*",
|
|
57
|
+
# Editor / IDE configs (low architectural value)
|
|
58
|
+
".editorconfig", ".pre-commit-config.yaml",
|
|
59
|
+
".env", ".env.example", ".envrc",
|
|
60
|
+
# OS noise
|
|
61
|
+
".DS_Store", "Thumbs.db",
|
|
62
|
+
# Common metadata files that are just lists
|
|
63
|
+
"MANIFEST.in", ".dockerignore", ".npmignore",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Dataclasses
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ModelConfig:
|
|
73
|
+
"""LLM settings for a single analysis run."""
|
|
74
|
+
|
|
75
|
+
name: str = "qwen2.5-coder:14b"
|
|
76
|
+
ollama_url: str = "http://localhost:11434"
|
|
77
|
+
num_ctx: int = 32768 # ← 8192 → 16384 → 32768
|
|
78
|
+
temperature: float = 0.0
|
|
79
|
+
timeout: int = 1200
|
|
80
|
+
max_tokens_file: int = 1000
|
|
81
|
+
max_tokens_guide: int = 8000
|
|
82
|
+
|
|
83
|
+
def validate(self) -> None:
|
|
84
|
+
"""Raise ValueError if any field is out of acceptable range."""
|
|
85
|
+
if self.num_ctx < 512 or self.num_ctx > 131072:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"num_ctx must be between 512 and 131072, got {self.num_ctx}"
|
|
88
|
+
)
|
|
89
|
+
if not 0.0 <= self.temperature <= 2.0:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"temperature must be between 0.0 and 2.0, got {self.temperature}"
|
|
92
|
+
)
|
|
93
|
+
if self.timeout < 1:
|
|
94
|
+
raise ValueError(f"timeout must be positive, got {self.timeout}")
|
|
95
|
+
if not self.ollama_url.startswith(("http://", "https://")):
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"ollama_url must start with http:// or https://, "
|
|
98
|
+
f"got {self.ollama_url}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class AnalysisConfig:
|
|
104
|
+
"""Settings controlling what gets analyzed and how."""
|
|
105
|
+
|
|
106
|
+
exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUSIONS))
|
|
107
|
+
priority_files: list[str] = field(default_factory=list)
|
|
108
|
+
use_cache: bool = True
|
|
109
|
+
max_file_chars: int = 30000
|
|
110
|
+
|
|
111
|
+
def validate(self) -> None:
|
|
112
|
+
if self.max_file_chars < 1000:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"max_file_chars must be at least 1000, "
|
|
115
|
+
f"got {self.max_file_chars}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class ProjectConfig:
|
|
121
|
+
"""Complete configuration for a single project."""
|
|
122
|
+
|
|
123
|
+
name: str
|
|
124
|
+
path: Path
|
|
125
|
+
model: ModelConfig = field(default_factory=ModelConfig)
|
|
126
|
+
analysis: AnalysisConfig = field(default_factory=AnalysisConfig)
|
|
127
|
+
created: str = ""
|
|
128
|
+
last_run: str = ""
|
|
129
|
+
notes: str = ""
|
|
130
|
+
|
|
131
|
+
def validate(self) -> None:
|
|
132
|
+
"""Validate project config and all nested configs."""
|
|
133
|
+
if not self.name:
|
|
134
|
+
raise ValueError("Project name cannot be empty")
|
|
135
|
+
if not self.path:
|
|
136
|
+
raise ValueError("Project path cannot be empty")
|
|
137
|
+
self.model.validate()
|
|
138
|
+
self.analysis.validate()
|
|
139
|
+
|
|
140
|
+
def to_dict(self) -> dict[str, Any]:
|
|
141
|
+
"""Convert to a dictionary suitable for TOML serialization."""
|
|
142
|
+
data = asdict(self)
|
|
143
|
+
# Path → str for TOML
|
|
144
|
+
data["path"] = str(self.path)
|
|
145
|
+
return data
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_dict(cls, data: dict[str, Any]) -> ProjectConfig:
|
|
149
|
+
"""Build a ProjectConfig from a dictionary (loaded from TOML)."""
|
|
150
|
+
data = dict(data) # copy — do not mutate caller's data
|
|
151
|
+
|
|
152
|
+
model_data = data.pop("model", {})
|
|
153
|
+
analysis_data = data.pop("analysis", {})
|
|
154
|
+
|
|
155
|
+
return cls(
|
|
156
|
+
name=data["name"],
|
|
157
|
+
path=Path(data["path"]),
|
|
158
|
+
model=ModelConfig(**model_data),
|
|
159
|
+
analysis=AnalysisConfig(**analysis_data),
|
|
160
|
+
created=data.get("created", ""),
|
|
161
|
+
last_run=data.get("last_run", ""),
|
|
162
|
+
notes=data.get("notes", ""),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Slug helper
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def project_slug(name: str) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Convert a project name to a safe filename slug.
|
|
173
|
+
|
|
174
|
+
>>> project_slug("My Project")
|
|
175
|
+
'my-project'
|
|
176
|
+
>>> project_slug("SHL Policy Editor v2")
|
|
177
|
+
'shl-policy-editor-v2'
|
|
178
|
+
"""
|
|
179
|
+
slug = name.lower().strip()
|
|
180
|
+
slug = re.sub(r"[^\w\s-]", "", slug)
|
|
181
|
+
slug = re.sub(r"[-\s]+", "-", slug)
|
|
182
|
+
return slug.strip("-") or "unnamed"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Load / Save
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def project_path(name: str, projects_dir: Path | None = None) -> Path:
|
|
190
|
+
"""Return the TOML file path for a given project name."""
|
|
191
|
+
base = projects_dir if projects_dir is not None else PROJECTS_DIR
|
|
192
|
+
return base / f"{project_slug(name)}.toml"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def save_project(
|
|
196
|
+
config: ProjectConfig,
|
|
197
|
+
projects_dir: Path | None = None,
|
|
198
|
+
) -> Path:
|
|
199
|
+
"""
|
|
200
|
+
Save a project configuration to disk.
|
|
201
|
+
|
|
202
|
+
Returns the path where it was written.
|
|
203
|
+
"""
|
|
204
|
+
config.validate()
|
|
205
|
+
path = project_path(config.name, projects_dir)
|
|
206
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
|
|
208
|
+
with open(path, "wb") as f:
|
|
209
|
+
tomli_w.dump(config.to_dict(), f)
|
|
210
|
+
|
|
211
|
+
return path
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_project(
|
|
215
|
+
name: str,
|
|
216
|
+
projects_dir: Path | None = None,
|
|
217
|
+
) -> ProjectConfig:
|
|
218
|
+
"""Load a project configuration by name."""
|
|
219
|
+
path = project_path(name, projects_dir)
|
|
220
|
+
if not path.exists():
|
|
221
|
+
raise FileNotFoundError(f"Project '{name}' not found at {path}")
|
|
222
|
+
|
|
223
|
+
with open(path, "rb") as f:
|
|
224
|
+
data = tomllib.load(f)
|
|
225
|
+
|
|
226
|
+
return ProjectConfig.from_dict(data)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def list_projects(projects_dir: Path | None = None) -> list[ProjectConfig]:
|
|
230
|
+
"""List all saved projects, sorted by last run (newest first)."""
|
|
231
|
+
base = projects_dir if projects_dir is not None else PROJECTS_DIR
|
|
232
|
+
if not base.exists():
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
projects: list[ProjectConfig] = []
|
|
236
|
+
for toml_file in sorted(base.glob("*.toml")):
|
|
237
|
+
try:
|
|
238
|
+
with open(toml_file, "rb") as f:
|
|
239
|
+
data = tomllib.load(f)
|
|
240
|
+
projects.append(ProjectConfig.from_dict(data))
|
|
241
|
+
except (OSError, tomllib.TOMLDecodeError, KeyError, ValueError):
|
|
242
|
+
# Skip malformed files silently — they don't break the listing
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
projects.sort(key=lambda p: p.last_run or p.created or "", reverse=True)
|
|
246
|
+
return projects
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def delete_project(
|
|
250
|
+
name: str,
|
|
251
|
+
projects_dir: Path | None = None,
|
|
252
|
+
) -> bool:
|
|
253
|
+
"""Delete a project configuration. Returns True if deleted."""
|
|
254
|
+
path = project_path(name, projects_dir)
|
|
255
|
+
if path.exists():
|
|
256
|
+
path.unlink()
|
|
257
|
+
return True
|
|
258
|
+
return False
|