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