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/report.py ADDED
@@ -0,0 +1,256 @@
1
+ """Human-readable and JSON reports for a plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from typing import Any
8
+
9
+ from diffcone.model import SnapshotInfo
10
+ from diffcone.planner import Decision, Plan, Reason
11
+
12
+ # 2: unresolved_relationships.matched_affected_symbols (was matched_changed_symbols)
13
+ # 3: analysis.evidence (null for a static plan) and the evidence reason rules
14
+ SCHEMA_VERSION = 3
15
+
16
+ SCOPE_DESCRIPTION = {
17
+ "granularity": "whole functions, methods, classes and modules",
18
+ "resolved": [
19
+ "module-level functions, methods, classes and simple module-level assignments "
20
+ "(NAME = <expr>, bound once) as symbols",
21
+ "direct name and attribute references resolvable through module-level "
22
+ "definitions, import aliases, star imports within source roots, or self/cls",
23
+ "absolute and relative imports within source roots",
24
+ "class attribute lookup through the in-scope MRO, including super()",
25
+ "importlib.import_module / getattr with literal arguments",
26
+ "transitive dependencies",
27
+ "manifest-declared entry and lifecycle dependencies",
28
+ ],
29
+ "not_resolved": [
30
+ "type inference and dynamic dispatch on receivers of unknown type",
31
+ "instance attributes and arbitrary object.method() calls (name-bounded fallback)",
32
+ "dynamic imports / getattr / eval with non-literal arguments (dynamic fallback)",
33
+ "code outside the source roots and installed packages (assumed unchanged)",
34
+ ],
35
+ }
36
+
37
+
38
+ def _reason_dict(reason: Reason) -> dict[str, Any]:
39
+ return {
40
+ "rule": reason.rule,
41
+ "conservative": reason.conservative,
42
+ "detail": reason.detail,
43
+ "changed_symbol": reason.changed_symbol,
44
+ "changes": list(reason.changes),
45
+ "path": [asdict(step) | {"revisions": list(step.revisions)} for step in reason.path],
46
+ }
47
+
48
+
49
+ def _target_dict(decision: Decision) -> dict[str, Any]:
50
+ t = decision.target
51
+ return {
52
+ "runner": t.runner,
53
+ "runner_id": t.runner_id,
54
+ "entry_symbol": t.entry_symbol,
55
+ "lifecycle_dependencies": list(t.lifecycle_dependencies),
56
+ }
57
+
58
+
59
+ def snapshot_to_dict(info: SnapshotInfo) -> dict[str, Any]:
60
+ return {
61
+ "revision": info.revision,
62
+ "commit": info.commit,
63
+ "kind": info.kind,
64
+ "uncommitted": not info.committed,
65
+ "description": info.description,
66
+ }
67
+
68
+
69
+ def to_dict(plan: Plan) -> dict[str, Any]:
70
+ selected = [d for d in plan.decisions if d.selected]
71
+ unselected = [d for d in plan.decisions if not d.selected]
72
+ return {
73
+ "schema_version": SCHEMA_VERSION,
74
+ "status": "degraded" if plan.degraded else "complete",
75
+ # A runner may collect tests that are not targets: the plan is not a
76
+ # judgement on those, so running only its targets would skip them.
77
+ "discovery_incomplete": bool(plan.incomplete_discovery),
78
+ # Dependencies the project stated that the analysis cannot see.
79
+ "declarations": [
80
+ {"from": d.source, "to": d.target, "why": d.why} for d in plan.declarations
81
+ ],
82
+ "analysis": {
83
+ "repo": plan.repo,
84
+ "base": snapshot_to_dict(plan.base),
85
+ "head": snapshot_to_dict(plan.head),
86
+ "source_roots": plan.source_roots,
87
+ "working_tree_analyzed": plan.working_tree_analyzed,
88
+ "uncommitted_analyzed": plan.uncommitted_analyzed,
89
+ "scope": SCOPE_DESCRIPTION | {"analyzed": plan.scope_statement},
90
+ "evidence": plan.evidence,
91
+ "counts": {
92
+ "modules_base": len(plan.base_index.modules),
93
+ "modules_head": len(plan.head_index.modules),
94
+ "symbols_base": len(plan.base_index.symbols),
95
+ "symbols_head": len(plan.head_index.symbols),
96
+ "targets": len(plan.decisions),
97
+ "selected": len(selected),
98
+ "unselected": len(unselected),
99
+ },
100
+ },
101
+ "changed_symbols": [
102
+ {
103
+ "id": c.id,
104
+ "kind": c.kind,
105
+ "changes": list(c.changes),
106
+ "base_path": c.base.path if c.base else None,
107
+ "head_path": c.head.path if c.head else None,
108
+ }
109
+ for c in plan.changes
110
+ ],
111
+ "selected_targets": [
112
+ _target_dict(d)
113
+ | {
114
+ "rules": sorted({r.rule for r in d.reasons}),
115
+ "conservative": any(r.conservative for r in d.reasons),
116
+ "affected_dependencies": d.affected_dependencies,
117
+ }
118
+ for d in selected
119
+ ],
120
+ "unselected_targets": [
121
+ _target_dict(d) | {"reason": d.unselected_reason} for d in unselected
122
+ ],
123
+ "dependency_explanations": [
124
+ {"runner": d.target.runner, "runner_id": d.target.runner_id}
125
+ | {"reasons": [_reason_dict(r) for r in d.reasons]}
126
+ for d in selected
127
+ ],
128
+ "unresolved_relationships": [
129
+ {
130
+ "symbol": u.symbol,
131
+ "kind": u.kind,
132
+ "name": u.name,
133
+ "detail": u.detail,
134
+ "revisions": list(u.revisions),
135
+ "matched_affected_symbols": list(u.matched_affected_symbols),
136
+ }
137
+ for u in plan.unresolved
138
+ ],
139
+ "fallback_decisions": [
140
+ {"rule": f.rule, "scope": f.scope, "target": f.target, "detail": f.detail}
141
+ for f in plan.fallbacks
142
+ ],
143
+ "analysis_errors": [
144
+ {"revision": e.revision, "path": e.path, "message": e.message} for e in plan.errors
145
+ ],
146
+ "discovery": [
147
+ {
148
+ "runner": d.runner,
149
+ "targets": len(d.targets),
150
+ "config": d.config,
151
+ "notes": [{"kind": n.kind, "detail": n.detail} for n in d.notes],
152
+ }
153
+ for d in plan.discovery
154
+ ],
155
+ }
156
+
157
+
158
+ def to_json(plan: Plan, indent: int | None = 2) -> str:
159
+ return json.dumps(to_dict(plan), indent=indent, sort_keys=False) + "\n"
160
+
161
+
162
+ def _format_path(reason: Reason) -> str:
163
+ if not reason.path:
164
+ return ""
165
+ parts = [reason.path[0].source]
166
+ for step in reason.path:
167
+ parts.append(f"-[{step.kind}{':' + step.detail if step.detail else ''}]-> {step.target}")
168
+ return " ".join(parts)
169
+
170
+
171
+ def to_text(plan: Plan) -> str:
172
+ lines: list[str] = []
173
+ lines.append(f"diffcone plan: {plan.base.revision} -> {plan.head.revision}")
174
+ lines.append(f"source roots: {', '.join(plan.source_roots)}")
175
+ lines.append(f"base: {plan.base.description}")
176
+ lines.append(f"head: {plan.head.description}")
177
+ lines.append(f"scope: {plan.scope_statement}")
178
+ lines.append(f"status: {'DEGRADED' if plan.degraded else 'complete'}")
179
+ if plan.evidence is not None:
180
+ ev = plan.evidence
181
+ lines.append(
182
+ f"evidence: {ev['tests']} tests recorded at {ev['commit'][:12]} "
183
+ f"(environment {ev['environment_hash']}, PYTHONHASHSEED={ev['hash_seed']}); "
184
+ f"planned {', '.join(ev['planned'])}"
185
+ )
186
+ if ev["changes_outside_range"]:
187
+ lines.append(
188
+ f" {len(ev['changes_outside_range'])} change(s) since the evidence lie outside "
189
+ "base..head and were planned too"
190
+ )
191
+ if plan.declarations:
192
+ lines.append(f"declared dependencies ({len(plan.declarations)}):")
193
+ for d in plan.declarations:
194
+ lines.append(f" {d.source} -> {d.target}" + (f" ({d.why})" if d.why else ""))
195
+ if plan.incomplete_discovery:
196
+ lines.append(
197
+ f"discovery INCOMPLETE: {len(plan.incomplete_discovery)} place(s) where a runner "
198
+ "may collect tests that are not targets (see the notes below)"
199
+ )
200
+ lines.append("")
201
+ lines.append(f"changed symbols ({len(plan.changes)}):")
202
+ for c in plan.changes or []:
203
+ lines.append(f" {c.id} [{c.kind}] {', '.join(c.changes)}")
204
+ if not plan.changes:
205
+ lines.append(" (none)")
206
+ lines.append("")
207
+ selected = plan.selected
208
+ lines.append(f"selected targets ({len(selected)}):")
209
+ for d in selected:
210
+ flag = " (conservative)" if any(r.conservative for r in d.reasons) else ""
211
+ lines.append(f" {d.target.runner}: {d.target.runner_id}{flag}")
212
+ for r in d.reasons:
213
+ path = _format_path(r)
214
+ lines.append(f" - {r.rule}: {r.detail}")
215
+ if path:
216
+ lines.append(f" {path}")
217
+ if not selected:
218
+ lines.append(" (none)")
219
+ lines.append("")
220
+ unselected = plan.unselected
221
+ lines.append(f"unselected targets ({len(unselected)}):")
222
+ for d in unselected:
223
+ lines.append(f" {d.target.runner}: {d.target.runner_id}")
224
+ if not unselected:
225
+ lines.append(" (none)")
226
+ matched = [u for u in plan.unresolved if u.matched_affected_symbols]
227
+ dynamic = [u for u in plan.unresolved if u.kind == "dynamic"]
228
+ lines.append("")
229
+ lines.append(
230
+ f"unresolved relationships: {len(plan.unresolved)} "
231
+ f"({len(matched)} matching an affected symbol, {len(dynamic)} dynamic)"
232
+ )
233
+ for u in matched:
234
+ lines.append(
235
+ f" {u.symbol}: {u.kind} {u.detail!r} may refer to "
236
+ f"{', '.join(u.matched_affected_symbols)}"
237
+ )
238
+ for u in dynamic:
239
+ lines.append(f" {u.symbol}: dynamic {u.detail}")
240
+ if plan.fallbacks:
241
+ lines.append("")
242
+ lines.append(f"fallback decisions ({len(plan.fallbacks)}):")
243
+ for f in plan.fallbacks:
244
+ where = f" [{f.target}]" if f.target else ""
245
+ lines.append(f" {f.rule} ({f.scope}){where}: {f.detail}")
246
+ if plan.errors:
247
+ lines.append("")
248
+ lines.append(f"analysis errors ({len(plan.errors)}):")
249
+ for e in plan.errors:
250
+ lines.append(f" {e.revision}:{e.path}: {e.message}")
251
+ for d in plan.discovery:
252
+ lines.append("")
253
+ lines.append(f"discovery ({d.runner}): {len(d.targets)} target(s), {len(d.notes)} note(s)")
254
+ for n in d.notes:
255
+ lines.append(f" {n.kind}: {n.detail}")
256
+ return "\n".join(lines) + "\n"
diffcone/selection.py ADDED
@@ -0,0 +1,133 @@
1
+ """Keep only the tests ``diffcone run`` selected: a pytest plugin.
2
+
3
+ ``run`` used to name the selected tests on pytest's command line. pytest
4
+ imports the conftests of command-line paths while it parses its
5
+ configuration, and there a conftest that skips at module level
6
+ (``pytest.importorskip("tables")`` in pandas' ``tests/io/pytables``) raises
7
+ ``Skipped`` past pytest's handler and kills the session; collected the
8
+ ordinary way, the same conftest skips its directory. So pytest collects from
9
+ its own starting points, as a full run does, and this plugin deselects every
10
+ item whose test is not selected.
11
+
12
+ Loaded into the project's process as ``-p diffcone_select`` (a link to this
13
+ file, as with the recorder), so it imports nothing of diffcone. Settings, from
14
+ the environment:
15
+
16
+ * ``DIFFCONE_SELECT``: a directory holding ``selected``, one selected test per
17
+ line (``path::Class::test``, parameter cases folded), and ``targets``, every
18
+ target of the plan; each process writes ``missing-<pid>`` there, the
19
+ selected tests it did not collect, leaving out those under a directory or
20
+ module that skipped at collection, and ``unknown-<pid>``, the collected
21
+ tests that are no target at all: those are kept and run, since the plan
22
+ cannot say whether a change reaches them (a plugin's own collection, an
23
+ option discovery did not see);
24
+ * ``DIFFCONE_COLLECT_ROOT``: the repository, which those paths are relative
25
+ to (pytest's own node ids are relative to its rootdir, which may differ);
26
+ * ``DIFFCONE_OUTCOMES``: a directory; each process writes ``outcomes-<pid>``,
27
+ one ``nodeid<TAB>OUTCOME`` line per report (``PASSED``, ``FAILED``,
28
+ ``SKIPPED``, ``XFAIL``, ``XPASS``, ``ERROR``), as ``validate`` reads them:
29
+ pytest's console output depends on the project's verbosity options.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import re
36
+
37
+ import pytest
38
+
39
+ DIRECTORY = os.environ.get("DIFFCONE_SELECT")
40
+ OUTCOMES = os.environ.get("DIFFCONE_OUTCOMES")
41
+ _ROOT = os.environ.get("DIFFCONE_COLLECT_ROOT") or os.getcwd()
42
+ # The repository as given and with symlinks resolved: pytest reports paths
43
+ # under whichever its rootdir is, and a linked or relative ``--repo`` differs.
44
+ ROOTS = tuple(dict.fromkeys([os.path.realpath(_ROOT), os.path.abspath(_ROOT)]))
45
+ skipped: list[str] = [] # repository-relative paths of collectors that skipped
46
+
47
+
48
+ def _relative(where) -> str:
49
+ path = os.path.abspath(str(where))
50
+ for root in ROOTS:
51
+ if path.startswith(root + os.sep):
52
+ return os.path.relpath(path, root).replace(os.sep, "/")
53
+ return os.path.relpath(os.path.realpath(path), ROOTS[0]).replace(os.sep, "/")
54
+
55
+
56
+ def _key(item) -> str:
57
+ """The item's test as diffcone names it: the file relative to the
58
+ repository, then the rest of the node id without the parameter case."""
59
+ path = _relative(getattr(item, "path", None) or item.fspath) # ``path`` from pytest 7
60
+ if "::" not in item.nodeid:
61
+ return path
62
+ rest = re.sub(r"\[.*\]$", "", item.nodeid.split("::", 1)[1])
63
+ return path + "::" + rest
64
+
65
+
66
+ @pytest.hookimpl(hookwrapper=True)
67
+ def pytest_make_collect_report(collector):
68
+ outcome = yield
69
+ if DIRECTORY and outcome.get_result().skipped:
70
+ skipped.append(_relative(getattr(collector, "path", None) or collector.fspath))
71
+
72
+
73
+ @pytest.hookimpl(tryfirst=True)
74
+ def pytest_collection_modifyitems(session, config, items):
75
+ # First, so that a test the project's own options (-m, -k) deselect is
76
+ # still counted as collected.
77
+ if not DIRECTORY:
78
+ return
79
+ with open(os.path.join(DIRECTORY, "selected"), encoding="utf-8") as f:
80
+ wanted = {line for line in f.read().splitlines() if line}
81
+ targets_file = os.path.join(DIRECTORY, "targets")
82
+ known: set[str] | None = None
83
+ if os.path.exists(targets_file):
84
+ with open(targets_file, encoding="utf-8") as f:
85
+ known = {line for line in f.read().splitlines() if line}
86
+ keep, drop, found, unknown = [], [], set(), set()
87
+ for item in items:
88
+ key = _key(item)
89
+ if key in wanted:
90
+ keep.append(item)
91
+ found.add(key)
92
+ elif known is not None and key not in known:
93
+ keep.append(item)
94
+ unknown.add(key)
95
+ else:
96
+ drop.append(item)
97
+ with open(os.path.join(DIRECTORY, f"unknown-{os.getpid()}"), "w", encoding="utf-8") as f:
98
+ f.write("\n".join(sorted(unknown)))
99
+ if drop:
100
+ config.hook.pytest_deselected(items=drop)
101
+ items[:] = keep
102
+ with open(os.path.join(DIRECTORY, f"missing-{os.getpid()}"), "w", encoding="utf-8") as f:
103
+ f.write(
104
+ "\n".join(
105
+ sorted(
106
+ test
107
+ for test in wanted - found
108
+ if not any(
109
+ test.split("::", 1)[0] == s or test.startswith(s + "/") for s in skipped
110
+ )
111
+ )
112
+ )
113
+ )
114
+
115
+
116
+ def pytest_runtest_logreport(report):
117
+ if not OUTCOMES:
118
+ return
119
+ outcome = None
120
+ xfail = hasattr(report, "wasxfail")
121
+ if report.when == "call":
122
+ if xfail:
123
+ outcome = "XPASS" if report.passed else "XFAIL"
124
+ else:
125
+ outcome = report.outcome.upper()
126
+ elif report.failed:
127
+ outcome = "ERROR" # in setup or teardown
128
+ elif report.when == "setup" and report.skipped:
129
+ outcome = "XFAIL" if xfail else "SKIPPED"
130
+ if outcome is not None:
131
+ path = os.path.join(OUTCOMES, f"outcomes-{os.getpid()}")
132
+ with open(path, "a", encoding="utf-8") as f:
133
+ f.write(f"{report.nodeid}\t{outcome}\n")