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.
@@ -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())