pisces-cli 0.1.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.
- pisces/__init__.py +9 -0
- pisces/__main__.py +4 -0
- pisces/ast_visitor.py +347 -0
- pisces/cli.py +245 -0
- pisces/config_cmd.py +234 -0
- pisces/config_manager.py +208 -0
- pisces/context_manager.py +333 -0
- pisces/explain_cmd.py +608 -0
- pisces/fix_cmd.py +479 -0
- pisces/history_cmd.py +313 -0
- pisces/init_cmd.py +193 -0
- pisces/judge.py +131 -0
- pisces/llm_client.py +263 -0
- pisces/models.py +413 -0
- pisces/patch_engine.py +266 -0
- pisces/scan_cmd.py +230 -0
- pisces/scanner.py +677 -0
- pisces/tui.py +539 -0
- pisces/watcher.py +244 -0
- pisces_cli-0.1.0.dist-info/METADATA +448 -0
- pisces_cli-0.1.0.dist-info/RECORD +24 -0
- pisces_cli-0.1.0.dist-info/WHEEL +4 -0
- pisces_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pisces_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.context_manager
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Discovers Python files, manages hashing, drives AST extraction,
|
|
5
|
+
and persists the project intelligence to .pisces/context.json.
|
|
6
|
+
|
|
7
|
+
Core principle:
|
|
8
|
+
For each .py file:
|
|
9
|
+
read → SHA-256 → compare stored hash
|
|
10
|
+
unchanged → reuse existing context entry ← fast path
|
|
11
|
+
changed → re-parse AST → replace entry ← slow path (only when needed)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from pisces.ast_visitor import extract_file_context
|
|
24
|
+
from pisces.models import FileContext
|
|
25
|
+
from pisces.tui import (
|
|
26
|
+
console,
|
|
27
|
+
print_step_fail,
|
|
28
|
+
print_step_ok,
|
|
29
|
+
print_step_running,
|
|
30
|
+
print_warning,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# ─────────────────────────────────────────────────────────────
|
|
34
|
+
# Constants
|
|
35
|
+
# ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
CONTEXT_SCHEMA_VERSION = "1"
|
|
38
|
+
CONTEXT_FILENAME = "context.json"
|
|
39
|
+
|
|
40
|
+
# Default directories / patterns to always ignore
|
|
41
|
+
_DEFAULT_EXCLUDES = frozenset(
|
|
42
|
+
[
|
|
43
|
+
".git",
|
|
44
|
+
".venv",
|
|
45
|
+
"venv",
|
|
46
|
+
"__pycache__",
|
|
47
|
+
"node_modules",
|
|
48
|
+
".pisces",
|
|
49
|
+
"dist",
|
|
50
|
+
"build",
|
|
51
|
+
".mypy_cache",
|
|
52
|
+
".ruff_cache",
|
|
53
|
+
".pytest_cache",
|
|
54
|
+
".tox",
|
|
55
|
+
".eggs",
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ─────────────────────────────────────────────────────────────
|
|
61
|
+
# File hashing
|
|
62
|
+
# ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def hash_file(path: Path) -> str:
|
|
66
|
+
"""Return the SHA-256 hex digest of *path*."""
|
|
67
|
+
h = hashlib.sha256()
|
|
68
|
+
with path.open("rb") as f:
|
|
69
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
70
|
+
h.update(chunk)
|
|
71
|
+
return h.hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─────────────────────────────────────────────────────────────
|
|
75
|
+
# Python file discovery
|
|
76
|
+
# ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discover_python_files(
|
|
80
|
+
root: Path,
|
|
81
|
+
extra_excludes: list[str] | None = None,
|
|
82
|
+
) -> list[Path]:
|
|
83
|
+
"""
|
|
84
|
+
Recursively find all .py files under *root*, respecting exclusion rules.
|
|
85
|
+
|
|
86
|
+
Returns absolute Paths sorted for deterministic ordering.
|
|
87
|
+
"""
|
|
88
|
+
excludes = set(_DEFAULT_EXCLUDES)
|
|
89
|
+
if extra_excludes:
|
|
90
|
+
excludes.update(extra_excludes)
|
|
91
|
+
|
|
92
|
+
found: list[Path] = []
|
|
93
|
+
|
|
94
|
+
for directory, subdirectories, filenames in os.walk(root):
|
|
95
|
+
subdirectories[:] = [
|
|
96
|
+
name
|
|
97
|
+
for name in subdirectories
|
|
98
|
+
if not is_excluded_component(name, excludes)
|
|
99
|
+
]
|
|
100
|
+
found.extend(
|
|
101
|
+
Path(directory) / filename
|
|
102
|
+
for filename in filenames
|
|
103
|
+
if filename.endswith(".py")
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return sorted(found)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_excluded_component(part: str, excludes: set[str] | frozenset[str]) -> bool:
|
|
110
|
+
"""Return whether a path component belongs to an excluded directory."""
|
|
111
|
+
return part in excludes or part.startswith(".venv")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ─────────────────────────────────────────────────────────────
|
|
116
|
+
# Context JSON I/O
|
|
117
|
+
# ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _context_path(project_root: Path) -> Path:
|
|
121
|
+
return project_root / ".pisces" / CONTEXT_FILENAME
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_raw_context(project_root: Path) -> dict:
|
|
125
|
+
"""Load context.json and return raw dict. Returns empty skeleton on failure."""
|
|
126
|
+
ctx_path = _context_path(project_root)
|
|
127
|
+
if not ctx_path.exists():
|
|
128
|
+
return _empty_context(project_root)
|
|
129
|
+
try:
|
|
130
|
+
with ctx_path.open("r", encoding="utf-8") as f:
|
|
131
|
+
return json.load(f)
|
|
132
|
+
except (json.JSONDecodeError, OSError):
|
|
133
|
+
print_warning("context.json is corrupted — starting fresh.")
|
|
134
|
+
return _empty_context(project_root)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _empty_context(project_root: Path) -> dict:
|
|
138
|
+
return {
|
|
139
|
+
"schema_version": CONTEXT_SCHEMA_VERSION,
|
|
140
|
+
"project": {
|
|
141
|
+
"root": str(project_root),
|
|
142
|
+
"last_updated": datetime.now(timezone.utc).isoformat(),
|
|
143
|
+
},
|
|
144
|
+
"files": {},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def save_context(project_root: Path, raw: dict) -> None:
|
|
149
|
+
"""Atomically write context.json."""
|
|
150
|
+
ctx_path = _context_path(project_root)
|
|
151
|
+
ctx_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
raw["project"]["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
154
|
+
|
|
155
|
+
tmp_path = ctx_path.with_suffix(".json.tmp")
|
|
156
|
+
try:
|
|
157
|
+
tmp_path.write_text(json.dumps(raw, indent=2), encoding="utf-8")
|
|
158
|
+
tmp_path.replace(ctx_path) # atomic on most platforms
|
|
159
|
+
except OSError as e:
|
|
160
|
+
print_warning(f"Could not save context.json: {e}")
|
|
161
|
+
if tmp_path.exists():
|
|
162
|
+
tmp_path.unlink(missing_ok=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ─────────────────────────────────────────────────────────────
|
|
166
|
+
# Context Manager
|
|
167
|
+
# ─────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ContextManager:
|
|
171
|
+
"""
|
|
172
|
+
High-level interface for managing project context.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
cm = ContextManager(project_root, extra_excludes=[".build"])
|
|
176
|
+
stats = cm.update()
|
|
177
|
+
print(f"Updated {stats['updated']} files, skipped {stats['skipped']}")
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
project_root: Path,
|
|
183
|
+
extra_excludes: list[str] | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
self.project_root = project_root
|
|
186
|
+
self.extra_excludes = extra_excludes or []
|
|
187
|
+
self._raw: dict = {} # in-memory copy of context.json
|
|
188
|
+
|
|
189
|
+
# ── Public API ────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def load(self) -> None:
|
|
192
|
+
"""Load context.json into memory."""
|
|
193
|
+
self._raw = load_raw_context(self.project_root)
|
|
194
|
+
|
|
195
|
+
def save(self) -> None:
|
|
196
|
+
"""Persist in-memory context to disk."""
|
|
197
|
+
save_context(self.project_root, self._raw)
|
|
198
|
+
|
|
199
|
+
def update(self, verbose: bool = True) -> dict:
|
|
200
|
+
"""
|
|
201
|
+
Scan all Python files and update context.json incrementally.
|
|
202
|
+
|
|
203
|
+
Returns stats dict: {discovered, updated, skipped, errors, elapsed_s}
|
|
204
|
+
"""
|
|
205
|
+
self.load()
|
|
206
|
+
|
|
207
|
+
t0 = time.monotonic()
|
|
208
|
+
py_files = discover_python_files(self.project_root, extra_excludes=self.extra_excludes)
|
|
209
|
+
|
|
210
|
+
stats = {
|
|
211
|
+
"discovered": len(py_files),
|
|
212
|
+
"updated": 0,
|
|
213
|
+
"skipped": 0,
|
|
214
|
+
"errors": 0,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if verbose:
|
|
218
|
+
console.print(f" [dim]Discovered[/] [cyan]{len(py_files)}[/] [dim]Python file(s)[/]")
|
|
219
|
+
|
|
220
|
+
existing_files: dict = self._raw.setdefault("files", {})
|
|
221
|
+
|
|
222
|
+
for py_file in py_files:
|
|
223
|
+
rel = str(py_file.relative_to(self.project_root))
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
file_hash = hash_file(py_file)
|
|
227
|
+
except OSError as e:
|
|
228
|
+
if verbose:
|
|
229
|
+
print_step_fail(f"Cannot read {rel}: {e}")
|
|
230
|
+
stats["errors"] += 1
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# ─ Incremental check ─────────────────────────
|
|
234
|
+
stored = existing_files.get(rel, {})
|
|
235
|
+
if stored.get("hash") == file_hash:
|
|
236
|
+
stats["skipped"] += 1
|
|
237
|
+
continue # ← fast path: file unchanged
|
|
238
|
+
|
|
239
|
+
# ─ File changed or new → re-parse AST ────────
|
|
240
|
+
if verbose:
|
|
241
|
+
print_step_running(f"Parsing {rel} ...")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
source = py_file.read_text(encoding="utf-8", errors="replace")
|
|
245
|
+
except OSError as e:
|
|
246
|
+
if verbose:
|
|
247
|
+
print_step_fail(f"Cannot read source of {rel}: {e}")
|
|
248
|
+
stats["errors"] += 1
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
fc = extract_file_context(
|
|
252
|
+
file_path=py_file,
|
|
253
|
+
relative_path=rel,
|
|
254
|
+
file_hash=file_hash,
|
|
255
|
+
source=source,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if fc.parse_error and verbose:
|
|
259
|
+
print_warning(f"Parse warning in {rel}: {fc.parse_error}")
|
|
260
|
+
|
|
261
|
+
existing_files[rel] = fc.to_dict()
|
|
262
|
+
stats["updated"] += 1
|
|
263
|
+
|
|
264
|
+
if verbose:
|
|
265
|
+
print_step_ok(f"{rel}")
|
|
266
|
+
|
|
267
|
+
# Remove entries for files that no longer exist
|
|
268
|
+
live_rels = {str(f.relative_to(self.project_root)) for f in py_files}
|
|
269
|
+
stale = [k for k in existing_files if k not in live_rels]
|
|
270
|
+
for k in stale:
|
|
271
|
+
del existing_files[k]
|
|
272
|
+
if verbose:
|
|
273
|
+
print_step_ok(f"Removed stale entry: {k}")
|
|
274
|
+
|
|
275
|
+
self._raw["files"] = existing_files
|
|
276
|
+
self.save()
|
|
277
|
+
|
|
278
|
+
stats["elapsed_s"] = round(time.monotonic() - t0, 3)
|
|
279
|
+
return stats
|
|
280
|
+
|
|
281
|
+
def update_single_file(self, file_path: Path) -> bool:
|
|
282
|
+
"""
|
|
283
|
+
Update context for a single file (used by watch mode).
|
|
284
|
+
|
|
285
|
+
Returns True if context was changed.
|
|
286
|
+
"""
|
|
287
|
+
self.load()
|
|
288
|
+
|
|
289
|
+
rel = str(file_path.relative_to(self.project_root))
|
|
290
|
+
|
|
291
|
+
if not file_path.exists():
|
|
292
|
+
# File deleted — remove from context
|
|
293
|
+
if rel in self._raw.get("files", {}):
|
|
294
|
+
del self._raw["files"][rel]
|
|
295
|
+
self.save()
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
file_hash = hash_file(file_path)
|
|
301
|
+
except OSError:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
stored = self._raw.get("files", {}).get(rel, {})
|
|
305
|
+
if stored.get("hash") == file_hash:
|
|
306
|
+
return False # unchanged
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
310
|
+
except OSError:
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
fc = extract_file_context(
|
|
314
|
+
file_path=file_path,
|
|
315
|
+
relative_path=rel,
|
|
316
|
+
file_hash=file_hash,
|
|
317
|
+
source=source,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
self._raw.setdefault("files", {})[rel] = fc.to_dict()
|
|
321
|
+
self.save()
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
def get_file_context(self, rel_path: str) -> FileContext | None:
|
|
325
|
+
"""Return a FileContext for a relative path, or None if not found."""
|
|
326
|
+
data = self._raw.get("files", {}).get(rel_path)
|
|
327
|
+
if data is None:
|
|
328
|
+
return None
|
|
329
|
+
return FileContext.from_dict(data)
|
|
330
|
+
|
|
331
|
+
def all_files(self) -> list[str]:
|
|
332
|
+
"""Return all tracked relative file paths."""
|
|
333
|
+
return list(self._raw.get("files", {}).keys())
|