graphite-code 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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/usage_ledger.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Machine-local usage ledger backing the savings display.
|
|
2
|
+
|
|
3
|
+
Everything here is best-effort by contract: recording, toggling, and cursor
|
|
4
|
+
IO must never break the command or hook that calls them, so every public
|
|
5
|
+
function swallows its own errors. The ledger lives under ``.graphite/local/``,
|
|
6
|
+
which the standard gitignore lines already ignore (``**/.graphite/``).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterator
|
|
15
|
+
|
|
16
|
+
from .io import atomic_write_text
|
|
17
|
+
|
|
18
|
+
MAX_LEDGER_BYTES = 5 * 1024 * 1024
|
|
19
|
+
MAX_FILES_PER_ENTRY = 100
|
|
20
|
+
_FILE_KEYS = frozenset({"source_file", "file", "path"})
|
|
21
|
+
_FILE_LIST_KEYS = frozenset({"impacted_files", "likely_tests", "files"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def local_dir(root: Path) -> Path:
|
|
25
|
+
return root / ".graphite" / "local"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ledger_path(root: Path) -> Path:
|
|
29
|
+
return local_dir(root) / "usage.jsonl"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _settings_path(root: Path) -> Path:
|
|
33
|
+
return local_dir(root) / "settings.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cursor_path(root: Path) -> Path:
|
|
37
|
+
return local_dir(root) / "stop-cursor.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rotated_ledger_path(root: Path) -> Path:
|
|
41
|
+
path = ledger_path(root)
|
|
42
|
+
return path.parent / (path.name + ".1")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_answer_files(result: Any, root: Path, cap: int = MAX_FILES_PER_ENTRY) -> list[dict[str, Any]]:
|
|
46
|
+
"""File paths named by an answer, with on-disk sizes; best-effort, capped."""
|
|
47
|
+
seen: dict[str, None] = {}
|
|
48
|
+
|
|
49
|
+
def _walk(value: Any) -> None:
|
|
50
|
+
if len(seen) >= cap:
|
|
51
|
+
return
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
for key, item in value.items():
|
|
54
|
+
if key in _FILE_KEYS and isinstance(item, str) and item:
|
|
55
|
+
seen.setdefault(item)
|
|
56
|
+
elif key in _FILE_LIST_KEYS and isinstance(item, list):
|
|
57
|
+
for path in item:
|
|
58
|
+
if isinstance(path, str) and path:
|
|
59
|
+
seen.setdefault(path)
|
|
60
|
+
if len(seen) >= cap:
|
|
61
|
+
return
|
|
62
|
+
else:
|
|
63
|
+
_walk(item)
|
|
64
|
+
elif isinstance(value, list):
|
|
65
|
+
for item in value:
|
|
66
|
+
_walk(item)
|
|
67
|
+
|
|
68
|
+
_walk(result)
|
|
69
|
+
files: list[dict[str, Any]] = []
|
|
70
|
+
for path in list(seen)[:cap]:
|
|
71
|
+
size = 0
|
|
72
|
+
try:
|
|
73
|
+
candidate = Path(path)
|
|
74
|
+
if not candidate.is_absolute():
|
|
75
|
+
candidate = root / candidate
|
|
76
|
+
size = candidate.stat().st_size
|
|
77
|
+
except OSError:
|
|
78
|
+
size = 0
|
|
79
|
+
files.append({"path": path, "bytes": size})
|
|
80
|
+
return files
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def record_usage(root: Path, *, cmd: str, wall_ms: int, result: Any) -> None:
|
|
84
|
+
"""Append one usage record; rotate at the byte cap; never raise."""
|
|
85
|
+
try:
|
|
86
|
+
try:
|
|
87
|
+
output_bytes = len(json.dumps(result, ensure_ascii=False))
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
output_bytes = 0
|
|
90
|
+
entry = {
|
|
91
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
92
|
+
"cmd": cmd,
|
|
93
|
+
"wall_ms": int(wall_ms),
|
|
94
|
+
"output_bytes": output_bytes,
|
|
95
|
+
"files": collect_answer_files(result, root),
|
|
96
|
+
}
|
|
97
|
+
path = ledger_path(root)
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
if path.exists() and path.stat().st_size > MAX_LEDGER_BYTES:
|
|
100
|
+
os.replace(path, rotated_ledger_path(root))
|
|
101
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
102
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
103
|
+
except Exception:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def iter_entries(root: Path) -> Iterator[dict[str, Any]]:
|
|
108
|
+
"""All entries, rotated generation first; corrupt lines skipped; never raises."""
|
|
109
|
+
try:
|
|
110
|
+
current = ledger_path(root)
|
|
111
|
+
for path in (rotated_ledger_path(root), current):
|
|
112
|
+
if not path.exists():
|
|
113
|
+
continue
|
|
114
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
115
|
+
try:
|
|
116
|
+
entry = json.loads(line)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(entry, dict):
|
|
120
|
+
yield entry
|
|
121
|
+
except Exception:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_local_json(path: Path) -> dict[str, Any]:
|
|
126
|
+
try:
|
|
127
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
128
|
+
except Exception:
|
|
129
|
+
return {}
|
|
130
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def savings_display_enabled(root: Path) -> bool:
|
|
134
|
+
value = _read_local_json(_settings_path(root)).get("savings_display")
|
|
135
|
+
return True if not isinstance(value, bool) else value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def set_savings_display(root: Path, enabled: bool) -> dict[str, Any]:
|
|
139
|
+
settings = _read_local_json(_settings_path(root))
|
|
140
|
+
settings["savings_display"] = bool(enabled)
|
|
141
|
+
try:
|
|
142
|
+
atomic_write_text(_settings_path(root), json.dumps(settings, ensure_ascii=False, indent=2) + "\n")
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
return settings
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_cursor(root: Path) -> dict[str, Any]:
|
|
149
|
+
return _read_local_json(_cursor_path(root))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_cursor(root: Path, cursor: dict[str, Any]) -> None:
|
|
153
|
+
try:
|
|
154
|
+
atomic_write_text(_cursor_path(root), json.dumps(cursor, ensure_ascii=False) + "\n")
|
|
155
|
+
except Exception:
|
|
156
|
+
return
|
graphite/validation.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Validation for Graphite graph artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_WINDOWS_ABS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ValidationIssue:
|
|
14
|
+
severity: str
|
|
15
|
+
code: str
|
|
16
|
+
message: str
|
|
17
|
+
path: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_graph_bundle(bundle: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
"""Validate the public graph.json bundle and return a machine-readable report."""
|
|
22
|
+
issues: list[ValidationIssue] = []
|
|
23
|
+
|
|
24
|
+
nodes = bundle.get("nodes")
|
|
25
|
+
edges = bundle.get("edges")
|
|
26
|
+
metadata = bundle.get("metadata")
|
|
27
|
+
clusters = bundle.get("clusters", [])
|
|
28
|
+
analysis = bundle.get("analysis", {})
|
|
29
|
+
|
|
30
|
+
if not isinstance(nodes, list):
|
|
31
|
+
issues.append(_error("nodes_type", "nodes must be a list", "nodes"))
|
|
32
|
+
nodes = []
|
|
33
|
+
if not isinstance(edges, list):
|
|
34
|
+
issues.append(_error("edges_type", "edges must be a list", "edges"))
|
|
35
|
+
edges = []
|
|
36
|
+
if not isinstance(metadata, dict):
|
|
37
|
+
issues.append(_error("metadata_type", "metadata must be an object", "metadata"))
|
|
38
|
+
metadata = {}
|
|
39
|
+
if not isinstance(clusters, list):
|
|
40
|
+
issues.append(_warn("clusters_type", "clusters should be a list", "clusters"))
|
|
41
|
+
clusters = []
|
|
42
|
+
if not isinstance(analysis, dict):
|
|
43
|
+
issues.append(_warn("analysis_type", "analysis should be an object", "analysis"))
|
|
44
|
+
|
|
45
|
+
node_ids: set[str] = set()
|
|
46
|
+
for index, node in enumerate(nodes):
|
|
47
|
+
path = f"nodes[{index}]"
|
|
48
|
+
if not isinstance(node, dict):
|
|
49
|
+
issues.append(_error("node_type", "node must be an object", path))
|
|
50
|
+
continue
|
|
51
|
+
node_id = node.get("id")
|
|
52
|
+
if not isinstance(node_id, str) or not node_id.strip():
|
|
53
|
+
issues.append(_error("node_id_missing", "node id must be a non-empty string", f"{path}.id"))
|
|
54
|
+
continue
|
|
55
|
+
if node_id in node_ids:
|
|
56
|
+
issues.append(_error("node_id_duplicate", f"duplicate node id: {node_id}", f"{path}.id"))
|
|
57
|
+
node_ids.add(node_id)
|
|
58
|
+
source_file = node.get("source_file")
|
|
59
|
+
if isinstance(source_file, str) and _is_absolute_or_unsafe_path(source_file):
|
|
60
|
+
issues.append(_error("absolute_source_file", f"source_file must be project-relative: {source_file}", f"{path}.source_file"))
|
|
61
|
+
|
|
62
|
+
seen_edges: set[tuple[str, str, str]] = set()
|
|
63
|
+
for index, edge in enumerate(edges):
|
|
64
|
+
path = f"edges[{index}]"
|
|
65
|
+
if not isinstance(edge, dict):
|
|
66
|
+
issues.append(_error("edge_type", "edge must be an object", path))
|
|
67
|
+
continue
|
|
68
|
+
source = edge.get("source")
|
|
69
|
+
target = edge.get("target")
|
|
70
|
+
relation = edge.get("relation")
|
|
71
|
+
if not isinstance(source, str) or not source:
|
|
72
|
+
issues.append(_error("edge_source_missing", "edge source must be a non-empty string", f"{path}.source"))
|
|
73
|
+
continue
|
|
74
|
+
if not isinstance(target, str) or not target:
|
|
75
|
+
issues.append(_error("edge_target_missing", "edge target must be a non-empty string", f"{path}.target"))
|
|
76
|
+
continue
|
|
77
|
+
if not isinstance(relation, str) or not relation:
|
|
78
|
+
issues.append(_error("edge_relation_missing", "edge relation must be a non-empty string", f"{path}.relation"))
|
|
79
|
+
continue
|
|
80
|
+
if source not in node_ids:
|
|
81
|
+
issues.append(_error("edge_source_unknown", f"edge source does not exist: {source}", f"{path}.source"))
|
|
82
|
+
if target not in node_ids:
|
|
83
|
+
issues.append(_error("edge_target_unknown", f"edge target does not exist: {target}", f"{path}.target"))
|
|
84
|
+
key = (source, target, relation)
|
|
85
|
+
if key in seen_edges:
|
|
86
|
+
issues.append(_warn("edge_duplicate", f"duplicate edge: {source} -> {target} ({relation})", path))
|
|
87
|
+
seen_edges.add(key)
|
|
88
|
+
source_file = edge.get("source_file")
|
|
89
|
+
if isinstance(source_file, str) and _is_absolute_or_unsafe_path(source_file):
|
|
90
|
+
issues.append(_error("absolute_edge_source_file", f"edge source_file must be project-relative: {source_file}", f"{path}.source_file"))
|
|
91
|
+
|
|
92
|
+
expected_nodes = metadata.get("node_count")
|
|
93
|
+
expected_edges = metadata.get("edge_count")
|
|
94
|
+
if isinstance(expected_nodes, int) and expected_nodes != len(nodes):
|
|
95
|
+
issues.append(_error("metadata_node_count", f"metadata node_count {expected_nodes} != actual {len(nodes)}", "metadata.node_count"))
|
|
96
|
+
if isinstance(expected_edges, int) and expected_edges != len(edges):
|
|
97
|
+
issues.append(_error("metadata_edge_count", f"metadata edge_count {expected_edges} != actual {len(edges)}", "metadata.edge_count"))
|
|
98
|
+
|
|
99
|
+
for cidx, cluster in enumerate(clusters):
|
|
100
|
+
if not isinstance(cluster, dict):
|
|
101
|
+
issues.append(_warn("cluster_type", "cluster should be an object", f"clusters[{cidx}]"))
|
|
102
|
+
continue
|
|
103
|
+
members = cluster.get("members", [])
|
|
104
|
+
if not isinstance(members, list):
|
|
105
|
+
issues.append(_warn("cluster_members_type", "cluster members should be a list", f"clusters[{cidx}].members"))
|
|
106
|
+
continue
|
|
107
|
+
for midx, member in enumerate(members):
|
|
108
|
+
if isinstance(member, str) and member not in node_ids:
|
|
109
|
+
issues.append(_warn("cluster_member_unknown", f"cluster member does not exist: {member}", f"clusters[{cidx}].members[{midx}]"))
|
|
110
|
+
|
|
111
|
+
if not nodes:
|
|
112
|
+
issues.append(_warn("empty_graph", "graph contains no nodes", "nodes"))
|
|
113
|
+
|
|
114
|
+
errors = [issue for issue in issues if issue.severity == "error"]
|
|
115
|
+
warnings = [issue for issue in issues if issue.severity == "warning"]
|
|
116
|
+
return {
|
|
117
|
+
"ok": not errors,
|
|
118
|
+
"error_count": len(errors),
|
|
119
|
+
"warning_count": len(warnings),
|
|
120
|
+
"node_count": len(nodes),
|
|
121
|
+
"edge_count": len(edges),
|
|
122
|
+
"errors": [asdict(issue) for issue in errors],
|
|
123
|
+
"warnings": [asdict(issue) for issue in warnings],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def assert_valid_graph_bundle(bundle: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
"""Validate or raise a concise ValueError suitable for build failure."""
|
|
129
|
+
report = validate_graph_bundle(bundle)
|
|
130
|
+
if not report["ok"]:
|
|
131
|
+
first = report["errors"][0]
|
|
132
|
+
raise ValueError(f"graph validation failed: {first['code']}: {first['message']} at {first['path']}")
|
|
133
|
+
return report
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _error(code: str, message: str, path: str) -> ValidationIssue:
|
|
137
|
+
return ValidationIssue("error", code, message, path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _warn(code: str, message: str, path: str) -> ValidationIssue:
|
|
141
|
+
return ValidationIssue("warning", code, message, path)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_absolute_or_unsafe_path(value: str) -> bool:
|
|
145
|
+
if _WINDOWS_ABS_RE.match(value) or value.startswith(("/", "\\")):
|
|
146
|
+
return True
|
|
147
|
+
parts = PurePosixPath(value.replace("\\", "/")).parts
|
|
148
|
+
return any(part == ".." for part in parts)
|
graphite/watch.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Lightweight local watcher for keeping Graphite graphs current."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Event
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .ingest import collect_files
|
|
12
|
+
|
|
13
|
+
Snapshot = dict[str, str]
|
|
14
|
+
SleepFn = Callable[[float], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class WatchChange:
|
|
19
|
+
"""File-hash changes detected between two Graphite scans."""
|
|
20
|
+
|
|
21
|
+
added: tuple[str, ...] = field(default_factory=tuple)
|
|
22
|
+
changed: tuple[str, ...] = field(default_factory=tuple)
|
|
23
|
+
removed: tuple[str, ...] = field(default_factory=tuple)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def paths(self) -> tuple[str, ...]:
|
|
27
|
+
return tuple(sorted(set(self.added).union(self.changed).union(self.removed)))
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def has_changes(self) -> bool:
|
|
31
|
+
return bool(self.added or self.changed or self.removed)
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, list[str]]:
|
|
34
|
+
return {
|
|
35
|
+
"added": list(self.added),
|
|
36
|
+
"changed": list(self.changed),
|
|
37
|
+
"removed": list(self.removed),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class WatchOptions:
|
|
43
|
+
interval_seconds: float = 1.5
|
|
44
|
+
debounce_seconds: float = 0.75
|
|
45
|
+
max_cycles: int | None = None
|
|
46
|
+
build_now: bool = True
|
|
47
|
+
once: bool = False
|
|
48
|
+
|
|
49
|
+
def validate(self) -> None:
|
|
50
|
+
if self.interval_seconds <= 0:
|
|
51
|
+
raise ValueError("watch interval must be greater than zero")
|
|
52
|
+
if self.debounce_seconds < 0:
|
|
53
|
+
raise ValueError("watch debounce must be zero or greater")
|
|
54
|
+
if self.max_cycles is not None and self.max_cycles <= 0:
|
|
55
|
+
raise ValueError("watch max cycles must be greater than zero")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def snapshot(root: Path, cfg: Config) -> Snapshot:
|
|
59
|
+
"""Return a stable content-hash snapshot for files Graphite would ingest."""
|
|
60
|
+
cfg = cfg.canonical_graph()
|
|
61
|
+
return {entry.rel_path: entry.content_hash for entry in collect_files(root, cfg)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def diff_snapshots(previous: Snapshot, current: Snapshot) -> WatchChange:
|
|
65
|
+
"""Compute deterministic file additions, content changes, and removals."""
|
|
66
|
+
previous_paths = set(previous)
|
|
67
|
+
current_paths = set(current)
|
|
68
|
+
added = tuple(sorted(current_paths - previous_paths))
|
|
69
|
+
removed = tuple(sorted(previous_paths - current_paths))
|
|
70
|
+
changed = tuple(sorted(path for path in previous_paths & current_paths if previous[path] != current[path]))
|
|
71
|
+
return WatchChange(added=added, changed=changed, removed=removed)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def wait_for_stable_snapshot(
|
|
75
|
+
root: Path,
|
|
76
|
+
cfg: Config,
|
|
77
|
+
first: Snapshot,
|
|
78
|
+
debounce_seconds: float,
|
|
79
|
+
*,
|
|
80
|
+
sleep: SleepFn = time.sleep,
|
|
81
|
+
max_rounds: int = 5,
|
|
82
|
+
) -> Snapshot:
|
|
83
|
+
"""Wait until the ingest snapshot stops changing across the debounce window."""
|
|
84
|
+
if debounce_seconds <= 0:
|
|
85
|
+
return first
|
|
86
|
+
|
|
87
|
+
candidate = first
|
|
88
|
+
for _ in range(max_rounds):
|
|
89
|
+
sleep(debounce_seconds)
|
|
90
|
+
next_snapshot = snapshot(root, cfg)
|
|
91
|
+
if next_snapshot == candidate:
|
|
92
|
+
return next_snapshot
|
|
93
|
+
candidate = next_snapshot
|
|
94
|
+
return candidate
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def watch_loop(
|
|
98
|
+
root: Path,
|
|
99
|
+
cfg: Config,
|
|
100
|
+
on_change: Callable[[WatchChange], bool],
|
|
101
|
+
options: WatchOptions,
|
|
102
|
+
*,
|
|
103
|
+
stop_event: Event | None = None,
|
|
104
|
+
sleep: SleepFn = time.sleep,
|
|
105
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
106
|
+
) -> int:
|
|
107
|
+
"""Poll for content changes and call `on_change` after debounced stable updates.
|
|
108
|
+
|
|
109
|
+
`on_change` returns True when downstream processing succeeded. Failed callbacks leave the
|
|
110
|
+
previous snapshot unchanged so the same change can be retried on the next cycle.
|
|
111
|
+
"""
|
|
112
|
+
options.validate()
|
|
113
|
+
cfg = cfg.canonical_graph()
|
|
114
|
+
root = root.resolve()
|
|
115
|
+
if not root.exists():
|
|
116
|
+
raise FileNotFoundError(root)
|
|
117
|
+
|
|
118
|
+
previous = snapshot(root, cfg)
|
|
119
|
+
processed = 0
|
|
120
|
+
|
|
121
|
+
if options.build_now:
|
|
122
|
+
initial = WatchChange(added=tuple(sorted(previous)))
|
|
123
|
+
if on_change(initial):
|
|
124
|
+
processed += 1
|
|
125
|
+
elif options.once:
|
|
126
|
+
return processed
|
|
127
|
+
|
|
128
|
+
cycles = 0
|
|
129
|
+
while True:
|
|
130
|
+
if stop_event and stop_event.is_set():
|
|
131
|
+
return processed
|
|
132
|
+
if options.once and cycles > 0:
|
|
133
|
+
return processed
|
|
134
|
+
if options.max_cycles is not None and cycles >= options.max_cycles:
|
|
135
|
+
return processed
|
|
136
|
+
|
|
137
|
+
sleep(options.interval_seconds)
|
|
138
|
+
cycles += 1
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
current = snapshot(root, cfg)
|
|
142
|
+
change = diff_snapshots(previous, current)
|
|
143
|
+
if not change.has_changes:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
stable = wait_for_stable_snapshot(
|
|
147
|
+
root,
|
|
148
|
+
cfg,
|
|
149
|
+
current,
|
|
150
|
+
options.debounce_seconds,
|
|
151
|
+
sleep=sleep,
|
|
152
|
+
)
|
|
153
|
+
change = diff_snapshots(previous, stable)
|
|
154
|
+
if not change.has_changes:
|
|
155
|
+
previous = stable
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if on_change(change):
|
|
159
|
+
previous = stable
|
|
160
|
+
processed += 1
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
if on_error:
|
|
163
|
+
on_error(exc)
|
|
164
|
+
else:
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
return processed
|