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/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")
|