diffcone 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.
diffcone/manifest.py ADDED
@@ -0,0 +1,166 @@
1
+ """Target manifest.
2
+
3
+ Tells the planner which runnable targets exist, which symbol each one enters
4
+ through, and which setup/fixture symbols it depends on: hand-written, or
5
+ written by ``diffcone discover`` from static discovery. Unknown keys are
6
+ errors, so a misspelt key cannot silently drop dependencies.
7
+
8
+ Format (JSON)::
9
+
10
+ {
11
+ "source_roots": ["src", "tests"], # optional, CLI overrides; "DIR=PREFIX" allowed
12
+ "targets": [
13
+ {
14
+ "runner": "pytest",
15
+ "runner_id": "tests/test_calc.py::test_add",
16
+ "entry_symbol": "tests.test_calc.test_add",
17
+ "lifecycle_dependencies": ["tests.conftest.db"]
18
+ }
19
+ ],
20
+ "discovery": {...} # optional, written by discover
21
+ }
22
+
23
+ ``discovery`` carries the notes discovery made (``runners[].notes``); notes
24
+ saying the target list may be short keep a plan made from the manifest at
25
+ exit code 3, as a plan that discovered the targets itself would be.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+
36
+ class ManifestError(Exception):
37
+ """The manifest is malformed."""
38
+
39
+
40
+ @dataclass(frozen=True, order=True)
41
+ class Target:
42
+ runner: str
43
+ runner_id: str
44
+ entry_symbol: str
45
+ lifecycle_dependencies: tuple[str, ...] = ()
46
+
47
+ @property
48
+ def node_id(self) -> str:
49
+ return f"target:{self.runner}:{self.runner_id}"
50
+
51
+
52
+ @dataclass
53
+ class Manifest:
54
+ targets: list[Target]
55
+ source_roots: list[str] | None = None
56
+ # (runner, kind, detail, path) for each note ``discover`` recorded.
57
+ notes: list[tuple[str, str, str, str]] = field(default_factory=list)
58
+
59
+
60
+ MANIFEST_KEYS = frozenset({"source_roots", "targets", "discovery"})
61
+ TARGET_KEYS = frozenset({"runner", "runner_id", "entry_symbol", "lifecycle_dependencies"})
62
+
63
+
64
+ def _unknown_keys(obj: dict[str, Any], allowed: frozenset[str], where: str) -> None:
65
+ unknown = sorted(set(obj) - allowed)
66
+ if unknown:
67
+ raise ManifestError(
68
+ f"{where}: unknown key(s) {', '.join(map(repr, unknown))} "
69
+ f"(allowed: {', '.join(sorted(allowed))})"
70
+ )
71
+
72
+
73
+ def _discovery_notes(discovery: Any) -> list[tuple[str, str, str, str]]:
74
+ if not isinstance(discovery, dict) or not isinstance(discovery.get("runners", []), list):
75
+ raise ManifestError("manifest: 'discovery' must be an object with a 'runners' list")
76
+ notes: list[tuple[str, str, str, str]] = []
77
+ for i, runner in enumerate(discovery.get("runners", [])):
78
+ where = f"discovery.runners[{i}]"
79
+ if not isinstance(runner, dict) or not isinstance(runner.get("notes", []), list):
80
+ raise ManifestError(f"{where}: must be an object with a 'notes' list")
81
+ name = _require_str(runner, "runner", where)
82
+ for j, note in enumerate(runner.get("notes", [])):
83
+ if not isinstance(note, dict):
84
+ raise ManifestError(f"{where}.notes[{j}]: must be an object")
85
+ path = note.get("path", "")
86
+ notes.append(
87
+ (
88
+ name,
89
+ _require_str(note, "kind", f"{where}.notes[{j}]"),
90
+ _require_str(note, "detail", f"{where}.notes[{j}]"),
91
+ path if isinstance(path, str) else "",
92
+ )
93
+ )
94
+ return notes
95
+
96
+
97
+ def _require_str(obj: dict[str, Any], key: str, where: str) -> str:
98
+ value = obj.get(key)
99
+ if not isinstance(value, str) or not value:
100
+ raise ManifestError(f"{where}: {key!r} must be a non-empty string")
101
+ return value
102
+
103
+
104
+ def parse_manifest(data: Any) -> Manifest:
105
+ if isinstance(data, list):
106
+ data = {"targets": data}
107
+ if not isinstance(data, dict):
108
+ raise ManifestError("manifest must be a JSON object or a list of targets")
109
+ _unknown_keys(data, MANIFEST_KEYS, "manifest")
110
+ raw_targets = data.get("targets")
111
+ if not isinstance(raw_targets, list):
112
+ raise ManifestError("manifest: 'targets' must be a list")
113
+ source_roots = data.get("source_roots")
114
+ if source_roots is not None and (
115
+ not isinstance(source_roots, list) or not all(isinstance(r, str) for r in source_roots)
116
+ ):
117
+ raise ManifestError("manifest: 'source_roots' must be a list of strings")
118
+
119
+ targets: list[Target] = []
120
+ seen: set[tuple[str, str]] = set()
121
+ for i, raw in enumerate(raw_targets):
122
+ where = f"targets[{i}]"
123
+ if not isinstance(raw, dict):
124
+ raise ManifestError(f"{where}: must be an object")
125
+ _unknown_keys(raw, TARGET_KEYS, where)
126
+ runner = _require_str(raw, "runner", where)
127
+ runner_id = _require_str(raw, "runner_id", where)
128
+ entry = _require_str(raw, "entry_symbol", where)
129
+ deps = raw.get("lifecycle_dependencies", [])
130
+ if not isinstance(deps, list) or not all(isinstance(d, str) and d for d in deps):
131
+ raise ManifestError(f"{where}: 'lifecycle_dependencies' must be a list of strings")
132
+ key = (runner, runner_id)
133
+ if key in seen:
134
+ raise ManifestError(f"{where}: duplicate target {runner}:{runner_id}")
135
+ seen.add(key)
136
+ targets.append(Target(runner, runner_id, entry, tuple(sorted(set(deps)))))
137
+ notes = _discovery_notes(data["discovery"]) if "discovery" in data else []
138
+ return Manifest(targets=targets, source_roots=source_roots, notes=notes)
139
+
140
+
141
+ def manifest_to_dict(
142
+ targets: list[Target], source_roots: list[str] | None = None
143
+ ) -> dict[str, Any]:
144
+ data: dict[str, Any] = {}
145
+ if source_roots:
146
+ data["source_roots"] = list(source_roots)
147
+ data["targets"] = [
148
+ {
149
+ "runner": t.runner,
150
+ "runner_id": t.runner_id,
151
+ "entry_symbol": t.entry_symbol,
152
+ "lifecycle_dependencies": list(t.lifecycle_dependencies),
153
+ }
154
+ for t in sorted(targets)
155
+ ]
156
+ return data
157
+
158
+
159
+ def load_manifest(path: str | Path) -> Manifest:
160
+ try:
161
+ data = json.loads(Path(path).read_text("utf-8"))
162
+ except (OSError, ValueError) as exc: # ValueError covers UnicodeDecodeError
163
+ raise ManifestError(f"cannot read manifest {path}: {exc}") from exc
164
+ except json.JSONDecodeError as exc:
165
+ raise ManifestError(f"manifest {path} is not valid JSON: {exc}") from exc
166
+ return parse_manifest(data)
diffcone/model.py ADDED
@@ -0,0 +1,191 @@
1
+ """Explicit data structures shared by the indexer, classifier and planner.
2
+
3
+ Symbol identity is the dotted qualified name (``pkg.mod``, ``pkg.mod.func``,
4
+ ``pkg.mod.Class.method``). Source locations are metadata only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from diffcone.cython import CythonModule
12
+
13
+ MODULE = "module"
14
+ CLASS = "class"
15
+ FUNCTION = "function"
16
+ METHOD = "method"
17
+ VARIABLE = "variable" # a simple module-level assignment ``NAME = <expr>``
18
+
19
+ # Edge kinds. ``source`` depends on ``target``.
20
+ REFERENCES = "references" # name/call/attribute reference resolved to a symbol
21
+ DEFINED_IN = "defined_in" # symbol is defined inside the target container
22
+ IMPORTS = "imports" # module-level import of a module (init-time dependency)
23
+ IMPORTS_NAME = "imports_name" # module-level ``from m import name`` of a symbol
24
+ UNRESOLVED_NAME_MATCH = "unresolved_name_match" # conservative edge synthesised by the planner
25
+ DECLARED = "declared" # dependency stated in diffcone.toml, not found by analysis
26
+ ENTRY = "entry" # target -> its entry symbol
27
+ LIFECYCLE = "lifecycle" # target -> declared setup/fixture dependency
28
+
29
+ # Unresolved reference kinds.
30
+ UNRESOLVED_NAME = "name" # bare name that resolves to nothing known
31
+ UNRESOLVED_ATTRIBUTE = "attribute" # ``<unknown>.name`` — bounded by the attribute name
32
+ UNRESOLVED_DYNAMIC = "dynamic" # getattr/importlib/eval with non-literal arguments
33
+ OPAQUE_ATTRIBUTE = "*" # SourceIndex.class_attributes: class-body code binding nothing by name
34
+ CLASS_STATEMENT = "(statement)" # SourceIndex.class_attributes: bases, keywords, decorators
35
+
36
+
37
+ @dataclass(frozen=True, order=True)
38
+ class Symbol:
39
+ id: str
40
+ kind: str
41
+ module: str
42
+ name: str
43
+ path: str
44
+ lineno: int
45
+ body_hash: str
46
+ definition_hash: str
47
+ container: str | None
48
+ # Source line spans of every definition of this symbol (metadata, not
49
+ # identity); used by coverage-based validation to map executed lines back
50
+ # to symbols.
51
+ line_ranges: tuple[tuple[int, int], ...] = ()
52
+ # Modules only: canonical import bindings ("import a as b", "from m import n"),
53
+ # sorted. Lets the classifier tell additions from removals/redirections.
54
+ imports: tuple[str, ...] = ()
55
+ # Modules only: each import binding with the block it sits in, in source
56
+ # order. Moving an import (under ``if TYPE_CHECKING:``, into a ``try``)
57
+ # or reordering imports changes what runs at import, though the set of
58
+ # bindings is the same: only pure insertions are ``imports_added``.
59
+ import_layout: tuple[str, ...] = ()
60
+ # Hash of the docstring alone; body_hash excludes it. A docstring-only edit
61
+ # is reported as docstring_changed and carries no impact, except where
62
+ # code runs it: under a non-inert decorator (or a class decorator or
63
+ # metaclass) the docstring is part of the definition hash, and a module
64
+ # whose own code names ``__doc__`` has it in its body hash.
65
+ docstring_hash: str = ""
66
+ # Functions: hash of the parameter and return annotations, which
67
+ # definition_hash excludes, and whether they are never evaluated at
68
+ # import (``from __future__ import annotations``, no decorator that could
69
+ # read them, a plain class): an annotation-only change then does not run
70
+ # at import (annotations_changed).
71
+ annotation_hash: str = ""
72
+ deferred_annotations: bool = False
73
+ # Functions: the ``def`` statement runs no code at import beyond binding
74
+ # the name (inert decorators, literal defaults, deferred or no
75
+ # annotations, a plain class for methods).
76
+ inert_definition: bool = False
77
+ # Functions: the decorators and defaults alone run nothing (annotations
78
+ # aside): adding such a function registers nothing anywhere.
79
+ inert_header: bool = False
80
+ # The symbol's own code reads a docstring (``obj.__doc__``, ``__doc__``,
81
+ # ``getdoc(obj)``): a docstring change of what it references, or of its
82
+ # module, reaches it.
83
+ reads_docstrings: bool = False
84
+
85
+
86
+ @dataclass(frozen=True, order=True)
87
+ class Edge:
88
+ source: str
89
+ target: str
90
+ kind: str
91
+ detail: str = ""
92
+
93
+
94
+ @dataclass(frozen=True, order=True)
95
+ class UnresolvedReference:
96
+ symbol: str
97
+ kind: str
98
+ name: str
99
+ detail: str
100
+
101
+
102
+ @dataclass(frozen=True, order=True)
103
+ class ExternalReference:
104
+ symbol: str
105
+ module: str
106
+
107
+
108
+ @dataclass(frozen=True, order=True)
109
+ class AnalysisError:
110
+ revision: str
111
+ path: str
112
+ message: str
113
+
114
+
115
+ KIND_COMMIT = "commit"
116
+ KIND_INDEX = "index"
117
+ KIND_WORKTREE = "worktree"
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class SnapshotInfo:
122
+ """What a snapshot is: carried unchanged from the reader to the report."""
123
+
124
+ revision: str
125
+ commit: str
126
+ kind: str = KIND_COMMIT
127
+ description: str = ""
128
+
129
+ @property
130
+ def committed(self) -> bool:
131
+ return self.kind == KIND_COMMIT
132
+
133
+ @property
134
+ def is_worktree(self) -> bool:
135
+ return self.kind == KIND_WORKTREE
136
+
137
+
138
+ @dataclass
139
+ class SourceIndex:
140
+ snapshot: SnapshotInfo
141
+ modules: set[str] = field(default_factory=set)
142
+ symbols: dict[str, Symbol] = field(default_factory=dict)
143
+ edges: set[Edge] = field(default_factory=set)
144
+ unresolved: set[UnresolvedReference] = field(default_factory=set)
145
+ external: set[ExternalReference] = field(default_factory=set)
146
+ errors: list[AnalysisError] = field(default_factory=list)
147
+ # Modules that failed to parse; their symbols are unknown in this revision.
148
+ failed_modules: set[str] = field(default_factory=set)
149
+ # Classes whose instances are passed to someone else, who may then read
150
+ # any attribute off them by a name nothing resolves.
151
+ escaped_classes: set[str] = field(default_factory=set)
152
+ # The non-Python files under the source roots: path -> git blob id. The
153
+ # index reads none of them, so the planner compares them whole.
154
+ other_files: dict[str, str] = field(default_factory=dict)
155
+ # Cython sources among them (.pyx, .pxd, .pxi) at function level: path ->
156
+ # module (diffcone.cython). Static planning does not use these; evidence
157
+ # mode diffs them to find the Cython functions a change touched.
158
+ cython: dict[str, CythonModule] = field(default_factory=dict)
159
+ # (symbol, detail): code that observes names or signatures reflectively
160
+ # without naming them (``dir``, ``hasattr``, ``inspect.signature``, a
161
+ # ``__dict__`` read). Static planning does not use these; evidence mode
162
+ # counts them as sites that notice an added, deleted or redefined name.
163
+ reflection: set[tuple[str, str]] = field(default_factory=set)
164
+ # Class -> {attribute bound in the class body: hash of its statements}.
165
+ # Class attributes are not symbols; evidence mode compares these to find
166
+ # which attribute names a class-body change touched. ``OPAQUE_ATTRIBUTE``
167
+ # hashes every other statement of the body (a loop, a call, a ``del``),
168
+ # ``CLASS_STATEMENT`` the bases, keywords and decorators.
169
+ class_attributes: dict[str, dict[str, str]] = field(default_factory=dict)
170
+ # Class -> the in-scope classes its statement names as bases (resolved).
171
+ # Evidence mode walks class hierarchies with it; static planning does not
172
+ # use it.
173
+ class_bases: dict[str, tuple[str, ...]] = field(default_factory=dict)
174
+ # Classes whose creation may run code that reads their attributes:
175
+ # decorators, keywords (a metaclass), or a base the index cannot see
176
+ # (``Enum``, a dataclass-like framework base). Evidence mode escalates an
177
+ # attribute change of one rather than trusting readers of the name.
178
+ open_classes: set[str] = field(default_factory=set)
179
+ # Functions and classes whose decorators (or metaclass) may read their
180
+ # docstring: one the analysis cannot see, or in-scope code that reads
181
+ # ``__doc__`` (pandas' ``@doc``). A docstring-only change of one runs at
182
+ # import; of any other symbol it runs nothing.
183
+ doc_decorated: set[str] = field(default_factory=set)
184
+
185
+ @property
186
+ def revision(self) -> str:
187
+ return self.snapshot.revision
188
+
189
+ @property
190
+ def commit(self) -> str:
191
+ return self.snapshot.commit