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.
@@ -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)}")