pytest-selective 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.
- pytest_selective/__init__.py +0 -0
- pytest_selective/__main__.py +3 -0
- pytest_selective/ast_imports.py +75 -0
- pytest_selective/ci.py +242 -0
- pytest_selective/hygiene.py +118 -0
- pytest_selective/impact_audit.py +104 -0
- pytest_selective/impact_audit_plugin.py +217 -0
- pytest_selective/impact_cli.py +432 -0
- pytest_selective/impact_declarations.py +123 -0
- pytest_selective/impact_explanation.py +481 -0
- pytest_selective/impact_first_party.py +362 -0
- pytest_selective/impact_graph.py +885 -0
- pytest_selective/impact_models.py +491 -0
- pytest_selective/impact_name_scope.py +498 -0
- pytest_selective/impact_selection.py +242 -0
- pytest_selective/input_policy.py +27 -0
- pytest_selective/local.py +78 -0
- pytest_selective/py.typed +0 -0
- pytest_selective/pytest_expansion.py +535 -0
- pytest_selective/pytest_sharding.py +269 -0
- pytest_selective/test_inventory.py +39 -0
- pytest_selective/typecheck_scope.py +111 -0
- pytest_selective/working_tree.py +43 -0
- pytest_selective-0.1.0.dist-info/METADATA +263 -0
- pytest_selective-0.1.0.dist-info/RECORD +28 -0
- pytest_selective-0.1.0.dist-info/WHEEL +4 -0
- pytest_selective-0.1.0.dist-info/entry_points.txt +3 -0
- pytest_selective-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Validated contracts for cached, conservative test impact analysis."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
from pytest_selective.input_policy import DEFAULT_GLOBAL_INPUTS
|
|
8
|
+
from pytest_selective.test_inventory import TEST_FILE_PATTERNS
|
|
9
|
+
|
|
10
|
+
#: Marks a graph node that stands for a file's bytes: its readers inspect it without executing it.
|
|
11
|
+
CONTENTS_PREFIX = "@contents:"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def contents_node(path: str) -> str:
|
|
15
|
+
"""The node a content read of `path` depends on; it never follows `path`'s own imports."""
|
|
16
|
+
return CONTENTS_PREFIX + path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def file_of(node: str) -> str:
|
|
20
|
+
"""The repository path a graph node stands for."""
|
|
21
|
+
return node.removeprefix(CONTENTS_PREFIX)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImpactModel(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DependencyInputs(ImpactModel):
|
|
29
|
+
"""Canonical input semantics, shared by central and reader-owned rules."""
|
|
30
|
+
|
|
31
|
+
inputs: list[str]
|
|
32
|
+
reason: str = ""
|
|
33
|
+
kind: Literal["content", "import"] = "import"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DependencyRule(DependencyInputs):
|
|
37
|
+
"""Legacy central rule, retaining its content-only default."""
|
|
38
|
+
|
|
39
|
+
readers: list[str]
|
|
40
|
+
kind: Literal["content", "import"] = "content"
|
|
41
|
+
reviewed_readers: dict[str, str] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ManifestDependencyRule(DependencyInputs):
|
|
45
|
+
inputs: list[str] = Field(min_length=1)
|
|
46
|
+
readers: list[str] | None = Field(default=None, min_length=1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DependencyManifest(ImpactModel):
|
|
50
|
+
dependencies: list[ManifestDependencyRule]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DjangoURLConfPolicy(ImpactModel):
|
|
54
|
+
"""Opt-in consumers of a deferred Django ROOT_URLCONF registration."""
|
|
55
|
+
|
|
56
|
+
settings_file: str
|
|
57
|
+
http_tests: list[str] = Field(min_length=1)
|
|
58
|
+
startup_tests: list[str] = Field(min_length=1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DeferredStringsRule(ImpactModel):
|
|
62
|
+
"""
|
|
63
|
+
Module names one file only stores, and the files that actually load them.
|
|
64
|
+
|
|
65
|
+
A module every test loads, such as Django settings, can name modules a framework imports only
|
|
66
|
+
in some processes: Celery imports ``CELERY_IMPORTS`` only when a worker starts. Inside each of
|
|
67
|
+
the ``assignments`` (each one top-level assignment in ``file``), those names stop being edges of
|
|
68
|
+
``file`` and become edges of every ``consumers`` file instead. Several settings the same
|
|
69
|
+
processes load (Celery's imports, routes and Beat schedule) share one rule and one consumer
|
|
70
|
+
list. The framework knowledge lives in the consumer's configuration; this rule only moves edges.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
file: str
|
|
74
|
+
#: Written ``"assignment": "X"`` for one, or ``"assignments": ["X", "Y"]`` for several.
|
|
75
|
+
assignments: list[str] = Field(min_length=1)
|
|
76
|
+
consumers: list[str] = Field(min_length=1)
|
|
77
|
+
#: Text that marks a file as loading the deferred modules (e.g. ``start_worker(``). Planning
|
|
78
|
+
#: fails when a file containing one is not a consumer, so a new loader can't silently lose them.
|
|
79
|
+
consumer_markers: list[str] = Field(default_factory=list)
|
|
80
|
+
reason: str = Field(min_length=1)
|
|
81
|
+
|
|
82
|
+
@model_validator(mode="before")
|
|
83
|
+
@classmethod
|
|
84
|
+
def _one_assignment_spelling(cls, data: object) -> object:
|
|
85
|
+
if isinstance(data, dict) and "assignment" in data:
|
|
86
|
+
if "assignments" in data:
|
|
87
|
+
raise ValueError("give either assignment or assignments, not both")
|
|
88
|
+
data = {**data, "assignments": [data["assignment"]]}
|
|
89
|
+
del data["assignment"]
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
@field_validator("assignments")
|
|
93
|
+
@classmethod
|
|
94
|
+
def _distinct_assignments(cls, assignments: list[str]) -> list[str]:
|
|
95
|
+
if len(set(assignments)) != len(assignments):
|
|
96
|
+
raise ValueError(f"assignments repeats a name: {assignments}")
|
|
97
|
+
return assignments
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class NameScopedModule(ImpactModel):
|
|
101
|
+
"""
|
|
102
|
+
A module whose readers depend only on the top-level names they read, not on the whole file.
|
|
103
|
+
|
|
104
|
+
A settings module, a constants module or a feature-flag table is read name by name, but every
|
|
105
|
+
importer depends on the whole file, so changing one value selects every test that loads it: every
|
|
106
|
+
test, when the module is global. Declared here, a change that only rebinds names selects the files
|
|
107
|
+
that read those names. A changed import or other code, a call ``pure_calls`` does not name, a name
|
|
108
|
+
code outside the repository reads, or an added or removed file keeps the module's ordinary edges.
|
|
109
|
+
What the module's framework reads is the consumer's knowledge, so it lives here.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
#: The module, repository-relative.
|
|
113
|
+
file: str
|
|
114
|
+
#: Tests that import the module. A narrowed change always runs them, so an edit that breaks
|
|
115
|
+
#: loading the module fails even when no test reads what it changed.
|
|
116
|
+
load_checks: list[str] = Field(min_length=1)
|
|
117
|
+
#: Objects that expose the module's names as attributes, as dotted import paths
|
|
118
|
+
#: (``django.conf.settings`` for Django settings). Reading one with a computed name
|
|
119
|
+
#: (``getattr(settings, name)``) makes a file a reader of every name.
|
|
120
|
+
accessors: list[str] = Field(default_factory=list)
|
|
121
|
+
#: Names code outside the repository reads (a framework, an installed package). Changing one
|
|
122
|
+
#: keeps the module's ordinary edges. `impact_name_scope.externally_read_names` computes them from
|
|
123
|
+
#: an installed environment.
|
|
124
|
+
external_names: list[str] = Field(default_factory=list)
|
|
125
|
+
#: Prefixes of names read outside the repository under a namespace (Celery reads ``CELERY_*``).
|
|
126
|
+
external_prefixes: list[str] = Field(default_factory=list)
|
|
127
|
+
#: Callables computing a name may call without side effects, as written in the file, with ``*``
|
|
128
|
+
#: for any run of characters (``config.*``, ``*.strip``). Any other call in a changed binding keeps
|
|
129
|
+
#: the module's ordinary edges, because the call runs when the module loads.
|
|
130
|
+
pure_calls: list[str] = Field(default_factory=list)
|
|
131
|
+
reason: str = Field(min_length=1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class DeferredRoute(ImpactModel):
|
|
135
|
+
"""A `DeferredStringsRule` resolved against one tree."""
|
|
136
|
+
|
|
137
|
+
file: str
|
|
138
|
+
#: Module names found only inside the rule's assignment.
|
|
139
|
+
strings: list[str]
|
|
140
|
+
consumers: list[str]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class FirstPartyPackage(ImpactModel):
|
|
144
|
+
"""
|
|
145
|
+
A locked dependency whose source repository this organization owns.
|
|
146
|
+
|
|
147
|
+
A consumer's lock and manifest are global test inputs, so a pin move runs every test. For a
|
|
148
|
+
package declared here the move is characterized instead: its old and new commits are fetched
|
|
149
|
+
from ``repository`` and only consumer files reaching a changed module are selected. Fetching is
|
|
150
|
+
only ever from ``repository``, never from a URL found in the lock under test.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
#: Distribution name as it appears in the lock, e.g. ``gigaverse-backend-common``.
|
|
154
|
+
name: str
|
|
155
|
+
#: Clone URL, exactly as the lock and manifest spell it before any ``?rev=``.
|
|
156
|
+
repository: str
|
|
157
|
+
#: Top-level import package, e.g. ``backend_common``.
|
|
158
|
+
import_prefix: str
|
|
159
|
+
#: Directory holding the importable package inside ``repository``; only its files install.
|
|
160
|
+
source_root: str = "src"
|
|
161
|
+
lock: str = "uv.lock"
|
|
162
|
+
manifest: str = "pyproject.toml"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class AuditExemption(ImpactModel):
|
|
166
|
+
"""
|
|
167
|
+
Reads the read audit accepts without a graph edge, because another lane already covers them.
|
|
168
|
+
|
|
169
|
+
Patterns use the declaration glob language, repository-root-relative, and each must match a
|
|
170
|
+
tracked file: an exemption left behind by a rename is an error, not a silent hole.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
readers: list[str] = Field(min_length=1)
|
|
174
|
+
inputs: list[str] = Field(min_length=1)
|
|
175
|
+
#: Names the lane that runs these readers when an input changes.
|
|
176
|
+
reason: str = Field(min_length=1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ImpactConfig(ImpactModel):
|
|
180
|
+
#: Environment changes can affect every test without any Python import edge.
|
|
181
|
+
global_inputs: list[str] = Field(default_factory=lambda: list(DEFAULT_GLOBAL_INPUTS))
|
|
182
|
+
#: Read by the audit only, so excluded from the graph's cache signature.
|
|
183
|
+
audit_exemptions: list[AuditExemption] = Field(default_factory=list)
|
|
184
|
+
#: The startup floor may not grow past this many files: `floor-budget` fails CI when a PR
|
|
185
|
+
#: does. Unset, nothing is checked. Read by that check only, so excluded from the graph's
|
|
186
|
+
#: cache signature like `audit_exemptions`.
|
|
187
|
+
floor_budget: int | None = Field(default=None, ge=0)
|
|
188
|
+
django_urlconf: DjangoURLConfPolicy | None = None
|
|
189
|
+
deferred_strings: list[DeferredStringsRule] = Field(default_factory=list)
|
|
190
|
+
#: Read at selection only, so excluded from the graph's cache signature.
|
|
191
|
+
name_scoped_modules: list[NameScopedModule] = Field(default_factory=list)
|
|
192
|
+
first_party_packages: list[FirstPartyPackage] = Field(default_factory=list)
|
|
193
|
+
pytest_policy: dict[str, str] = Field(default_factory=dict)
|
|
194
|
+
#: Repository-relative files passed through pytest -c; fingerprint their complete contents.
|
|
195
|
+
pytest_config_files: list[str] = Field(default_factory=list)
|
|
196
|
+
|
|
197
|
+
@field_validator("pytest_config_files")
|
|
198
|
+
@classmethod
|
|
199
|
+
def _pytest_config_paths(cls, paths: list[str]) -> list[str]:
|
|
200
|
+
for path in paths:
|
|
201
|
+
if (
|
|
202
|
+
not path
|
|
203
|
+
or path.startswith("/")
|
|
204
|
+
or any(c in path for c in "\\:*?[]")
|
|
205
|
+
or any(part in {"", ".", ".."} for part in path.split("/"))
|
|
206
|
+
):
|
|
207
|
+
raise ValueError("pytest_config_files must contain literal repository-relative POSIX paths")
|
|
208
|
+
return paths
|
|
209
|
+
|
|
210
|
+
module_roots: list[str] = Field(default_factory=lambda: ["."])
|
|
211
|
+
test_roots: list[str]
|
|
212
|
+
test_patterns: list[str] = Field(default_factory=lambda: list(TEST_FILE_PATTERNS))
|
|
213
|
+
scoped_hooks: dict[str, str] = Field(default_factory=dict)
|
|
214
|
+
global_modules: list[str] = Field(default_factory=list)
|
|
215
|
+
always_run: list[str] = Field(default_factory=list)
|
|
216
|
+
ignored_changes: list[str] = Field(default_factory=list)
|
|
217
|
+
dependencies: list[DependencyRule] = Field(default_factory=list)
|
|
218
|
+
|
|
219
|
+
@field_validator("name_scoped_modules")
|
|
220
|
+
@classmethod
|
|
221
|
+
def one_declaration_per_module(cls, modules: list[NameScopedModule]) -> list[NameScopedModule]:
|
|
222
|
+
files = [module.file for module in modules]
|
|
223
|
+
if len(files) != len(set(files)):
|
|
224
|
+
raise ValueError("name_scoped_modules declares a file more than once")
|
|
225
|
+
return modules
|
|
226
|
+
|
|
227
|
+
@field_validator("module_roots", "test_roots", "always_run", "ignored_changes", "global_inputs")
|
|
228
|
+
@classmethod
|
|
229
|
+
def relative_paths(cls, values: list[str]) -> list[str]:
|
|
230
|
+
if any(
|
|
231
|
+
value.startswith(("/", "\\")) or "\\" in value or ":" in value or ".." in value.split("/")
|
|
232
|
+
for value in values
|
|
233
|
+
):
|
|
234
|
+
raise ValueError("paths must be repository-relative POSIX paths")
|
|
235
|
+
return values
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ImportSpec(ImpactModel):
|
|
239
|
+
module: str
|
|
240
|
+
names: list[str] = Field(default_factory=list)
|
|
241
|
+
level: int = 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class DependencyComment(ImpactModel):
|
|
245
|
+
line: int
|
|
246
|
+
text: str
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class ParsedSource(ImpactModel):
|
|
250
|
+
django_urlconf: str | None = None
|
|
251
|
+
imports: list[ImportSpec]
|
|
252
|
+
strings: list[str]
|
|
253
|
+
opaque: list[str]
|
|
254
|
+
pytest_plugin: bool
|
|
255
|
+
dependencies: list[DependencyComment] = Field(default_factory=list)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class GitTree(ImpactModel):
|
|
259
|
+
tree: str
|
|
260
|
+
files: dict[str, str]
|
|
261
|
+
links: list[str]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Snapshot(ImpactModel):
|
|
265
|
+
tree: str
|
|
266
|
+
files: dict[str, str]
|
|
267
|
+
edges: dict[str, list[str]]
|
|
268
|
+
tests: list[str]
|
|
269
|
+
opaque: dict[str, list[str]]
|
|
270
|
+
unresolved: dict[str, list[str]]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class GraphAnalysisReport(ImpactModel):
|
|
274
|
+
"""Graph build statistics shared by cache warming and selection reports."""
|
|
275
|
+
|
|
276
|
+
head: str
|
|
277
|
+
total_files: int
|
|
278
|
+
elapsed_seconds: float = 0
|
|
279
|
+
parsed_blobs: int = 0
|
|
280
|
+
snapshot_hits: int = 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class AuditRecord(ImpactModel):
|
|
284
|
+
"""The tracked files each test file read or executed in one pytest process."""
|
|
285
|
+
|
|
286
|
+
#: Test file to the repository files it opened or passed to a subprocess, both repository-relative.
|
|
287
|
+
inputs: dict[str, list[str]] = Field(default_factory=dict)
|
|
288
|
+
#: Audit hook failures; the hook swallows them so it can never change a test's outcome.
|
|
289
|
+
errors: list[str] = Field(default_factory=list)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class UndeclaredRead(ImpactModel):
|
|
293
|
+
test: str
|
|
294
|
+
input: str
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class AuditReport(ImpactModel):
|
|
298
|
+
head: str
|
|
299
|
+
records: int
|
|
300
|
+
audited_tests: int
|
|
301
|
+
recorded_reads: int
|
|
302
|
+
#: Reads accepted without an edge: an `audit_exemptions` match, or a test in `always_run`.
|
|
303
|
+
exempted_reads: int
|
|
304
|
+
undeclared: list[UndeclaredRead]
|
|
305
|
+
recorder_errors: list[str]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class ImpactReport(GraphAnalysisReport):
|
|
309
|
+
mode: Literal["full", "scoped", "none"]
|
|
310
|
+
#: True when every declared first-party lock and manifest is unchanged, or differs only by pins
|
|
311
|
+
#: whose installed source is byte-identical. False whenever that is not proven, including when
|
|
312
|
+
#: nothing is declared. Lanes that treat a lock change as "everything changed" may skip on it.
|
|
313
|
+
dependencies_unchanged: bool = False
|
|
314
|
+
base: str
|
|
315
|
+
changed: list[str]
|
|
316
|
+
#: The paths traversal started from: `changed`, with a narrowed first-party pin replaced by the
|
|
317
|
+
#: files its moved modules reach. Explanations attribute selection to these, not to `changed`.
|
|
318
|
+
seeds: list[str] = Field(default_factory=list)
|
|
319
|
+
selected: list[str]
|
|
320
|
+
reasons: list[str]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
EdgeKind = Literal["import", "string", "package", "conftest", "startup", "declared"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class PathStep(ImpactModel):
|
|
327
|
+
"""One file on an import path, and how it depends on the next step."""
|
|
328
|
+
|
|
329
|
+
path: str
|
|
330
|
+
#: How this file depends on the next step; None on the last step.
|
|
331
|
+
kind: EdgeKind | None = None
|
|
332
|
+
#: The line that creates the dependency, when one line does (an import, a dotted string).
|
|
333
|
+
line: int | None = None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class SelectionRoute(ImpactModel):
|
|
337
|
+
"""A group of selected tests that reach the change the same way."""
|
|
338
|
+
|
|
339
|
+
#: `floor`: through a changed startup-floor file. `changed`: test files the change edits.
|
|
340
|
+
#: `funnel`: only through `via`. `other`: reached some other way. `unreached`: selected
|
|
341
|
+
#: without reaching the change (always-run tests, an explicit full suite, fixed-shard padding).
|
|
342
|
+
kind: Literal["floor", "changed", "funnel", "other", "unreached"]
|
|
343
|
+
tests: int
|
|
344
|
+
via: str | None = None
|
|
345
|
+
#: The enclosing funnel, for a funnel nested inside another.
|
|
346
|
+
parent: str | None = None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
#: How most tests load a floor entry, which decides what shrinks what it pulls in.
|
|
350
|
+
#: `conftest`: an ancestor conftest. `hooks`: a conftest whose pytest hooks make it global.
|
|
351
|
+
#: `startup`: a configured startup module. `package`: a package initializer, run by every import
|
|
352
|
+
#: of the package's modules. `import`: a module most tests import. `declared`: a declared input.
|
|
353
|
+
FloorEntryKind = Literal["conftest", "hooks", "startup", "package", "import", "declared"]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class FloorEntry(ImpactModel):
|
|
357
|
+
"""A file most test files depend on directly: the floor is whatever the entries load."""
|
|
358
|
+
|
|
359
|
+
path: str
|
|
360
|
+
kind: FloorEntryKind
|
|
361
|
+
#: Floor files this entry reaches, itself included.
|
|
362
|
+
floor_files: int
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class FloorCycle(ImpactModel):
|
|
366
|
+
"""
|
|
367
|
+
The floor's largest import cycle: every member reaches every other.
|
|
368
|
+
|
|
369
|
+
While the cycle holds, a startup file that loads one member loads all of them, so trimming
|
|
370
|
+
what startup loads leaves the whole cycle in the floor as long as any route into it remains.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
size: int
|
|
374
|
+
#: The member the most other members depend on directly, where the cycle is easiest to see.
|
|
375
|
+
hub: str
|
|
376
|
+
hub_readers: int
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ChangedFileReach(ImpactModel):
|
|
380
|
+
"""
|
|
381
|
+
One changed file's share of the selection: who loads it, and through what.
|
|
382
|
+
|
|
383
|
+
`own` separates what the startup floor adds: the selected tests that would still load this
|
|
384
|
+
file if the floor's entries (root conftest, startup modules, package initializers every test
|
|
385
|
+
loads) imported nothing. Outside the floor, the hub is the file most of the remaining indirect
|
|
386
|
+
loaders depend on directly on their way to the change, such as a directory conftest or a URL
|
|
387
|
+
configuration a policy attaches to every HTTP test. Tests that import the change directly are
|
|
388
|
+
its real users.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
path: str
|
|
392
|
+
#: Selected test files that depend on this file.
|
|
393
|
+
tests: int
|
|
394
|
+
#: Of those, test files that import it themselves.
|
|
395
|
+
direct: int
|
|
396
|
+
#: Of those, test files that still depend on it without the startup floor.
|
|
397
|
+
own: int
|
|
398
|
+
hub: str | None = None
|
|
399
|
+
#: Selected test files that depend on `hub` directly and reach this file through it.
|
|
400
|
+
hub_tests: int = 0
|
|
401
|
+
hub_kind: FloorEntryKind | None = None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class FloorChange(ImpactModel):
|
|
405
|
+
"""The startup floor: non-test Python files that at least `share` of test files depend on."""
|
|
406
|
+
|
|
407
|
+
share: float = Field(gt=0, le=1)
|
|
408
|
+
before: int
|
|
409
|
+
after: int
|
|
410
|
+
added: list[str] = Field(default_factory=list)
|
|
411
|
+
removed: list[str] = Field(default_factory=list)
|
|
412
|
+
#: How the first added file enters the floor, from a file every test loads.
|
|
413
|
+
path: list[PathStep] = Field(default_factory=list)
|
|
414
|
+
#: The floor on this PR, sorted.
|
|
415
|
+
files: list[str] = Field(default_factory=list)
|
|
416
|
+
#: Files most tests depend on directly, largest share of the floor first: the floor is
|
|
417
|
+
#: whatever these load, so they are where it shrinks.
|
|
418
|
+
entries: list[FloorEntry] = Field(default_factory=list)
|
|
419
|
+
cycle: FloorCycle | None = None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class BlastRadiusChange(ImpactModel):
|
|
423
|
+
"""A file whose change now selects many more test files than before this PR."""
|
|
424
|
+
|
|
425
|
+
path: str
|
|
426
|
+
before: int
|
|
427
|
+
after: int
|
|
428
|
+
#: A dependency this PR added that makes `path` reachable from more tests, then on to `path`.
|
|
429
|
+
cause: list[PathStep] = Field(default_factory=list)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class ExplanationUnavailable(ImpactModel):
|
|
433
|
+
"""
|
|
434
|
+
Written in place of an explanation when planning fell back to the complete suite.
|
|
435
|
+
|
|
436
|
+
It replaces any explanation an earlier attempt of the same run uploaded, which would otherwise
|
|
437
|
+
describe a plan this attempt did not run.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
unavailable: str = Field(min_length=1)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class SelectionExplanation(ImpactModel):
|
|
444
|
+
"""Why a selection is as large as it is, and whether the change widened the graph."""
|
|
445
|
+
|
|
446
|
+
base: str
|
|
447
|
+
head: str
|
|
448
|
+
selected: int
|
|
449
|
+
total: int
|
|
450
|
+
#: The complete suite ran because it was requested (a schedule, a dispatch, coverage, a label),
|
|
451
|
+
#: not because the change reaches every test. Such a run times something other than a PR run.
|
|
452
|
+
forced_full: bool = False
|
|
453
|
+
#: The tree floor and widening deltas compare against: the PR's merge base, else `base`.
|
|
454
|
+
graph_base: str
|
|
455
|
+
#: Traversal seeds that are startup-floor files: each alone selects nearly every test.
|
|
456
|
+
floor_seeds: list[str] = Field(default_factory=list)
|
|
457
|
+
routes: list[SelectionRoute] = Field(default_factory=list)
|
|
458
|
+
#: From the file the largest route passes through (or a startup root) to a changed file.
|
|
459
|
+
path: list[PathStep] = Field(default_factory=list)
|
|
460
|
+
floor: FloorChange
|
|
461
|
+
#: The changed files carrying the most selected tests, largest first.
|
|
462
|
+
changes: list[ChangedFileReach] = Field(default_factory=list)
|
|
463
|
+
widened: list[BlastRadiusChange] = Field(default_factory=list)
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def natural_full_suite(self) -> bool:
|
|
467
|
+
"""The change itself reached every test: a run comparable with ordinary PR runs."""
|
|
468
|
+
return self.full_suite and not self.forced_full
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def full_suite(self) -> bool:
|
|
472
|
+
return self.total > 0 and self.selected == self.total
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def graph_widened(self) -> bool:
|
|
476
|
+
"""
|
|
477
|
+
The floor grew, or a file now reaches many more tests.
|
|
478
|
+
|
|
479
|
+
Net growth, not any added file: splitting a module swaps a small new file in for the
|
|
480
|
+
larger ones that leave, which is what the floor advice asks for.
|
|
481
|
+
"""
|
|
482
|
+
return self.floor.after > self.floor.before or bool(self.widened)
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def graph_narrowed(self) -> bool:
|
|
486
|
+
return self.floor.after < self.floor.before and not self.widened
|
|
487
|
+
|
|
488
|
+
@property
|
|
489
|
+
def top_funnel(self) -> SelectionRoute | None:
|
|
490
|
+
"""The outermost funnel carrying the most selected tests; routes are ordered largest first."""
|
|
491
|
+
return next((route for route in self.routes if route.kind == "funnel" and route.parent is None), None)
|