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/declarations.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Dependencies a project declares that the analysis cannot see.
|
|
2
|
+
|
|
3
|
+
A registry filled at import time, a plugin resolved through entry points, a
|
|
4
|
+
handler named in a YAML file: the dependency is real and no static rule can
|
|
5
|
+
find it, so the project states it in ``diffcone.toml`` at the repository
|
|
6
|
+
root. The file is read from both snapshots being compared, so a declaration
|
|
7
|
+
is versioned with the code it describes, and one the change deletes still
|
|
8
|
+
counts.
|
|
9
|
+
|
|
10
|
+
# diffcone.toml
|
|
11
|
+
[[edges]]
|
|
12
|
+
from = "pkg.registry.dispatch" # a symbol or a module, in either revision
|
|
13
|
+
to = "pkg.handlers.json"
|
|
14
|
+
why = "handlers register themselves through entry points"
|
|
15
|
+
|
|
16
|
+
Declarations only *add* edges, so they can only widen selection: a wrong one
|
|
17
|
+
costs a test that runs anyway, and none of them can make the plan miss. A
|
|
18
|
+
declaration whose endpoints resolve to nothing, or a file that does not
|
|
19
|
+
parse, is an analysis error -- a typo that silently declares nothing is the
|
|
20
|
+
outcome worth failing on.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import tomllib
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from diffcone.snapshot import INDEX, WORKTREE, GitError, read_files, resolve_commit
|
|
30
|
+
|
|
31
|
+
FILENAME = "diffcone.toml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, order=True)
|
|
35
|
+
class Declaration:
|
|
36
|
+
source: str
|
|
37
|
+
target: str
|
|
38
|
+
why: str = ""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def detail(self) -> str:
|
|
42
|
+
return f"declared in {FILENAME}" + (f": {self.why}" if self.why else "")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Unreadable(Exception):
|
|
46
|
+
"""The file is there (or the snapshot cannot be read) but we could not
|
|
47
|
+
get at it. Distinct from absent, which is the normal case: silently
|
|
48
|
+
treating a read failure as "no declarations" narrows the plan."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_file(repo: Path, revision: str) -> bytes | None:
|
|
52
|
+
"""``diffcone.toml`` as the given snapshot has it, or None when absent."""
|
|
53
|
+
if revision == WORKTREE:
|
|
54
|
+
path = repo / FILENAME
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return path.read_bytes()
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
raise Unreadable(f"{FILENAME}: {exc}") from exc
|
|
61
|
+
try:
|
|
62
|
+
commit = "" if revision == INDEX else resolve_commit(repo, revision)
|
|
63
|
+
except GitError as exc: # an unknown revision is the caller's problem
|
|
64
|
+
raise Unreadable(f"{FILENAME}: {exc}") from exc
|
|
65
|
+
try:
|
|
66
|
+
return read_files(repo, commit, [FILENAME], label=revision).get(FILENAME)
|
|
67
|
+
except GitError:
|
|
68
|
+
return None # not in this snapshot: the ordinary case
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse(raw: bytes) -> tuple[list[Declaration], list[str]]:
|
|
72
|
+
"""Declarations and the problems found; a problem is an analysis error."""
|
|
73
|
+
try:
|
|
74
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
75
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
76
|
+
return [], [f"{FILENAME}: {exc}"]
|
|
77
|
+
problems: list[str] = []
|
|
78
|
+
edges: list[Declaration] = []
|
|
79
|
+
unknown_tables = sorted(set(data) - {"edges"})
|
|
80
|
+
if unknown_tables:
|
|
81
|
+
# A singular ``[[edge]]`` or a capitalisation slip would otherwise
|
|
82
|
+
# declare nothing at all, quietly.
|
|
83
|
+
problems.append(f"{FILENAME}: unknown top-level key(s) {', '.join(unknown_tables)}")
|
|
84
|
+
entries = data.get("edges", [])
|
|
85
|
+
if not isinstance(entries, list):
|
|
86
|
+
return [], [f"{FILENAME}: 'edges' must be a list of tables"]
|
|
87
|
+
for i, entry in enumerate(entries, start=1):
|
|
88
|
+
if not isinstance(entry, dict):
|
|
89
|
+
problems.append(f"{FILENAME}: edge {i} is not a table")
|
|
90
|
+
continue
|
|
91
|
+
unknown = sorted(set(entry) - {"from", "to", "why"})
|
|
92
|
+
if unknown:
|
|
93
|
+
problems.append(f"{FILENAME}: edge {i} has unknown key(s) {', '.join(unknown)}")
|
|
94
|
+
continue
|
|
95
|
+
source, target, why = entry.get("from"), entry.get("to"), entry.get("why", "")
|
|
96
|
+
if not isinstance(source, str) or not isinstance(target, str) or not (source and target):
|
|
97
|
+
problems.append(f"{FILENAME}: edge {i} needs a 'from' and a 'to'")
|
|
98
|
+
continue
|
|
99
|
+
if not isinstance(why, str):
|
|
100
|
+
problems.append(f"{FILENAME}: edge {i} has a non-string 'why'")
|
|
101
|
+
continue
|
|
102
|
+
edges.append(Declaration(source, target, why))
|
|
103
|
+
return edges, problems
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load(repo: Path, revision: str) -> tuple[list[Declaration], list[str]]:
|
|
107
|
+
try:
|
|
108
|
+
raw = read_file(repo, revision)
|
|
109
|
+
except Unreadable as exc:
|
|
110
|
+
return [], [str(exc)]
|
|
111
|
+
return parse(raw) if raw is not None else ([], [])
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Static runner discovery.
|
|
2
|
+
|
|
3
|
+
Discovery turns a head snapshot into manifest targets without importing or
|
|
4
|
+
executing project code. Each runner module documents the subset of its
|
|
5
|
+
runner's collection rules that it reproduces; anything outside that subset
|
|
6
|
+
surfaces as a note or as an unresolved lifecycle dependency, which the
|
|
7
|
+
planner treats conservatively.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from diffcone.manifest import Target
|
|
16
|
+
from diffcone.model import SourceIndex
|
|
17
|
+
from diffcone.snapshot import Snapshot
|
|
18
|
+
|
|
19
|
+
RUNNERS = ("pytest", "asv")
|
|
20
|
+
|
|
21
|
+
# Modules the runner's own process imports and runs, whoever the project
|
|
22
|
+
# is: when one of them (or anything under it) is in the source roots, the
|
|
23
|
+
# runner executes project code for every target it runs. pytest imports
|
|
24
|
+
# only ``packaging.version``/``packaging.requirements`` from packaging.
|
|
25
|
+
RUNNER_MODULES: dict[str, tuple[str, ...]] = {
|
|
26
|
+
"pytest": (
|
|
27
|
+
"_pytest",
|
|
28
|
+
"pytest",
|
|
29
|
+
"pluggy",
|
|
30
|
+
"iniconfig",
|
|
31
|
+
"exceptiongroup",
|
|
32
|
+
"tomli",
|
|
33
|
+
"colorama",
|
|
34
|
+
"packaging.version",
|
|
35
|
+
"packaging.requirements",
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, order=True)
|
|
41
|
+
class DiscoveryNote:
|
|
42
|
+
runner: str
|
|
43
|
+
kind: str
|
|
44
|
+
detail: str
|
|
45
|
+
# The file the note is about, for the notes that mean the target list
|
|
46
|
+
# may be short: evidence mode checks whether it changed since a
|
|
47
|
+
# recording settled the note (roadmap item 9).
|
|
48
|
+
path: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class DiscoveryOptions:
|
|
53
|
+
# Fixture names supplied by installed plugins that should not be treated
|
|
54
|
+
# as unresolved, in addition to the well-known ones
|
|
55
|
+
# (``pytest_static.WELL_KNOWN_PLUGIN_FIXTURES``).
|
|
56
|
+
external_fixtures: frozenset[str] = frozenset()
|
|
57
|
+
# Consult the well-known plugin fixture table; every assumed name is
|
|
58
|
+
# reported in an ``external_fixture`` discovery note.
|
|
59
|
+
well_known_fixtures: bool = True
|
|
60
|
+
# pytest options the run adds after ``--``, read as if they were in
|
|
61
|
+
# ``addopts`` (``--doctest-modules`` collects doctests; options discovery
|
|
62
|
+
# cannot model make it incomplete).
|
|
63
|
+
runner_args: tuple[str, ...] = ()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Note kinds that mean the target list may be short of what the runner
|
|
67
|
+
# collects. The others are conservative: an unknown fixture or an unparsed
|
|
68
|
+
# file makes a target's dependencies wider, never the target list shorter.
|
|
69
|
+
INCOMPLETE_NOTE_KINDS = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"uncollected_test_class",
|
|
72
|
+
"imported_test_out_of_scope",
|
|
73
|
+
"unknown_base_class",
|
|
74
|
+
"plugin_collects_files",
|
|
75
|
+
# A file pytest collects that could not be parsed or named: whatever
|
|
76
|
+
# tests it holds are not targets.
|
|
77
|
+
"unparsed_file",
|
|
78
|
+
# A file pytest collects lies outside the source roots, so it was
|
|
79
|
+
# never read: its tests are not targets.
|
|
80
|
+
"test_file_outside_roots",
|
|
81
|
+
# A module or class attribute with a test's name bound to something
|
|
82
|
+
# discovery cannot follow (``TestMachine = Machine.TestCase``).
|
|
83
|
+
"unmodelled_test_binding",
|
|
84
|
+
# An option given to pytest after ``--`` that discovery cannot model.
|
|
85
|
+
"unmodelled_runner_option",
|
|
86
|
+
# Evidence mode: pytest collected tests at the recorded commit that
|
|
87
|
+
# were not targets there.
|
|
88
|
+
"collected_not_target",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class DiscoveryResult:
|
|
95
|
+
runner: str
|
|
96
|
+
targets: list[Target] = field(default_factory=list)
|
|
97
|
+
notes: list[DiscoveryNote] = field(default_factory=list)
|
|
98
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def incomplete(self) -> list[DiscoveryNote]:
|
|
102
|
+
"""Notes saying this runner may collect tests that are not targets."""
|
|
103
|
+
return [n for n in self.notes if n.kind in INCOMPLETE_NOTE_KINDS]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def discover(
|
|
107
|
+
runner: str,
|
|
108
|
+
snapshot: Snapshot,
|
|
109
|
+
index: SourceIndex,
|
|
110
|
+
options: DiscoveryOptions | None = None,
|
|
111
|
+
) -> DiscoveryResult:
|
|
112
|
+
options = options or DiscoveryOptions()
|
|
113
|
+
if runner == "pytest":
|
|
114
|
+
from diffcone.discovery.pytest_static import discover_pytest
|
|
115
|
+
|
|
116
|
+
return discover_pytest(snapshot, index, options)
|
|
117
|
+
if runner == "asv":
|
|
118
|
+
from diffcone.discovery.asv_static import discover_asv
|
|
119
|
+
|
|
120
|
+
return discover_asv(snapshot, index, options)
|
|
121
|
+
raise ValueError(f"unknown runner {runner!r}; expected one of {', '.join(RUNNERS)}")
|