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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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