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/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|