pytest-selective 1.1.2__tar.gz → 1.2.0__tar.gz

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.
Files changed (196) hide show
  1. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/ci.yml +4 -3
  2. pytest_selective-1.2.0/.release-please-manifest.json +3 -0
  3. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/CHANGELOG.md +7 -0
  4. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/CONTRIBUTING.md +5 -3
  5. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/PKG-INFO +2 -2
  6. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/README.md +1 -1
  7. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/conftest.py +2 -1
  8. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/runtime_deps.py +4 -0
  9. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/configuration.md +44 -1
  10. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/selection-health.md +2 -1
  11. pytest_selective-1.2.0/docs/shared-plan-execution.md +43 -0
  12. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/pyproject.toml +1 -1
  13. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/ci_file_config.py +1 -8
  14. pytest_selective-1.2.0/scripts/run_ci_tests.py +8 -0
  15. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/ci.py +8 -0
  16. pytest_selective-1.1.2/scripts/run_ci_tests.py → pytest_selective-1.2.0/src/pytest_selective/execution.py +18 -3
  17. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_audit.py +9 -4
  18. pytest_selective-1.2.0/src/pytest_selective/impact_audit_declarations.py +148 -0
  19. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_cli.py +58 -3
  20. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_graph.py +142 -21
  21. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_ci_admission.py +1 -0
  22. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_ci_test_execution.py +5 -5
  23. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_repository_ci.py +28 -3
  24. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_audit.py +34 -0
  25. pytest_selective-1.2.0/tests/testing/test_impact_audit_declarations.py +114 -0
  26. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_graph.py +69 -0
  27. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/uv.lock +1 -1
  28. pytest_selective-1.1.2/.release-please-manifest.json +0 -3
  29. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.dry-lint/baseline.json +0 -0
  30. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.dry-lint/progress.json +0 -0
  31. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.dry-lint.toml +0 -0
  32. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.gitattributes +0 -0
  33. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  34. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  35. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/dependabot.yml +0 -0
  36. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/test-lanes.json +0 -0
  37. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/validation-inputs.json +0 -0
  38. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/docs.yml +0 -0
  39. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/dry-lint-audit.yml +0 -0
  40. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/dry-lint-judge.yml +0 -0
  41. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/dry-lint.yml +0 -0
  42. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/observe-ci.yml +0 -0
  43. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/pr-title.yml +0 -0
  44. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/public-actions.yml +0 -0
  45. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/release-please.yml +0 -0
  46. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/release.yml +0 -0
  47. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/windows-performance.yml +0 -0
  48. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.gitignore +0 -0
  49. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.pre-commit-config.yaml +0 -0
  50. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.readthedocs.yaml +0 -0
  51. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/AGENTS.md +0 -0
  52. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/CLAUDE.md +0 -0
  53. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/LICENSE +0 -0
  54. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/PROVENANCE.md +0 -0
  55. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/RELEASING.md +0 -0
  56. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/action.yml +0 -0
  57. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/README.md +0 -0
  58. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/plan-lanes/action.yml +0 -0
  59. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/pytest-expansion/action.yml +0 -0
  60. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/test-impact/action.yml +0 -0
  61. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/validation-gate/action.yml +0 -0
  62. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/agentsync.toml +0 -0
  63. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/README.md +0 -0
  64. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/quint-connect.json +0 -0
  65. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/quint.ini +0 -0
  66. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/selection.qnt +0 -0
  67. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/selection_driver.py +0 -0
  68. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/test_selection_contract.py +0 -0
  69. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/README.md +0 -0
  70. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/actions.md +0 -0
  71. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/agents.md +0 -0
  72. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/banner.png +0 -0
  73. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/banner.svg +0 -0
  74. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/brand.json +0 -0
  75. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/icon.png +0 -0
  76. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/logo.svg +0 -0
  77. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/raster-sources.json +0 -0
  78. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/social-preview.png +0 -0
  79. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/social-preview.svg +0 -0
  80. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-ci.json +0 -0
  81. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-docs-ci.json +0 -0
  82. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-docs-widget.json +0 -0
  83. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-widget.json +0 -0
  84. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-input-replays.json +0 -0
  85. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-obligation-docs.json +0 -0
  86. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-obligation.json +0 -0
  87. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-routing.md +0 -0
  88. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-selective-docs.json +0 -0
  89. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22.md +0 -0
  90. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/obligation-baseline.json +0 -0
  91. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-after-3.11.xml +0 -0
  92. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-after-3.14.xml +0 -0
  93. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-after.json +0 -0
  94. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-control-3.11.xml +0 -0
  95. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-control-3.14.xml +0 -0
  96. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-control.json +0 -0
  97. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-earlier.json +0 -0
  98. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor.md +0 -0
  99. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/windows-runtime.md +0 -0
  100. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/ci-toolkit.md +0 -0
  101. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/ci.md +0 -0
  102. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/comparison-pytest-impacted.md +0 -0
  103. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/evidence.md +0 -0
  104. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/local-development.md +0 -0
  105. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/pytest-hygiene.md +0 -0
  106. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorial.md +0 -0
  107. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorials/adoption-checklist.md +0 -0
  108. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorials/sidecar-dependencies.md +0 -0
  109. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorials/skip-unrelated-ci.md +0 -0
  110. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/actions-directory-test-plan.md +0 -0
  111. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/codex-selector-correctness-performance-test-plan.md +0 -0
  112. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/codex-windows-git-performance-test-plan.md +0 -0
  113. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/graph-based-inputs-plan.md +0 -0
  114. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/impacted-improvements-test-plan.md +0 -0
  115. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/move-action-implementations-test-plan.md +0 -0
  116. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/public-action-onboarding-test-plan.md +0 -0
  117. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/ratchet-duplicates-zero-test-plan.md +0 -0
  118. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/rerun-selection-test-plan.md +0 -0
  119. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/assets/routing-comparison.svg +0 -0
  120. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/check_site.py +0 -0
  121. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/ecosystem.md +0 -0
  122. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/hooks.py +0 -0
  123. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/index.md +0 -0
  124. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/llms.txt +0 -0
  125. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/overrides/main.html +0 -0
  126. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/publishing.md +0 -0
  127. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/requirements.in +0 -0
  128. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/requirements.txt +0 -0
  129. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/mkdocs.yml +0 -0
  130. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/release-please-config.json +0 -0
  131. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/benchmark_graph.py +0 -0
  132. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/benchmark_parsing.py +0 -0
  133. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/benchmark_rust.py +0 -0
  134. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/check_docs.py +0 -0
  135. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/check_metadata.py +0 -0
  136. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/compare_impacted.py +0 -0
  137. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/measure_ci_runs.py +0 -0
  138. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/public_actions.py +0 -0
  139. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/render_brand.py +0 -0
  140. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/smoke_distribution.py +0 -0
  141. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/tutorial.py +0 -0
  142. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/skills/pytest-selective/SKILL.md +0 -0
  143. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/skills/pytest-selective/agents/openai.yaml +0 -0
  144. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/__init__.py +0 -0
  145. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/__main__.py +0 -0
  146. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/ast_imports.py +0 -0
  147. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/checkpoint.py +0 -0
  148. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/gate.py +0 -0
  149. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/git_operations.py +0 -0
  150. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/hygiene.py +0 -0
  151. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_audit_plugin.py +0 -0
  152. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_declarations.py +0 -0
  153. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_explanation.py +0 -0
  154. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_first_party.py +0 -0
  155. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_models.py +0 -0
  156. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_name_scope.py +0 -0
  157. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_selection.py +0 -0
  158. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/input_policy.py +0 -0
  159. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/local.py +0 -0
  160. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/parse_batch.py +0 -0
  161. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/plugin.py +0 -0
  162. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/py.typed +0 -0
  163. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/pytest_expansion.py +0 -0
  164. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/pytest_plugins.py +0 -0
  165. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/pytest_sharding.py +0 -0
  166. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/test_inventory.py +0 -0
  167. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/typecheck_scope.py +0 -0
  168. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/working_tree.py +0 -0
  169. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/__init__.py +0 -0
  170. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_checkpoint_policy.py +0 -0
  171. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_ci_measurements.py +0 -0
  172. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_distribution_metadata.py +0 -0
  173. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_hygiene.py +0 -0
  174. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_public_actions.py +0 -0
  175. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_pytest_expansion.py +0 -0
  176. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_release_workflow.py +0 -0
  177. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_repository_hygiene.py +0 -0
  178. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_repository_hygiene.py.gv-dependency.yaml +0 -0
  179. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_tutorial.py +0 -0
  180. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/__init__.py +0 -0
  181. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/git_repo.py +0 -0
  182. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_ast_imports.py +0 -0
  183. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_ci.py +0 -0
  184. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_gate.py +0 -0
  185. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_git_repo.py +0 -0
  186. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_global_inputs.py +0 -0
  187. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_explanation.py +0 -0
  188. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_first_party.py +0 -0
  189. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_name_scope.py +0 -0
  190. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_parse_batch.py +0 -0
  191. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_portability.py +0 -0
  192. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_pytest_plugin.py +0 -0
  193. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_pytest_plugins.py +0 -0
  194. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_pytest_sharding.py +0 -0
  195. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_typecheck_scope.py +0 -0
  196. {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_working_tree.py +0 -0
@@ -149,12 +149,13 @@ jobs:
149
149
  - name: Record coverage runtime
150
150
  run: uv run --locked python -m coverage --version
151
151
  - name: Execute the shared affected-file plan
152
- run: uv run --locked python scripts/run_ci_tests.py -q -n 4 --durations=20 --junitxml=test-timings.xml
152
+ run: uv run --locked pytest-selective execute-plan -- -q -n 4 --dist=worksteal --durations=20 --junitxml=test-timings.xml
153
153
  env:
154
154
  CI_PLANNER_RESULT: ${{ needs.select-tests.result }}
155
155
  CI_TEST_MODE: ${{ needs.select-tests.outputs.mode }}
156
156
  CI_TEST_FILES: ${{ needs.select-tests.outputs.files }}
157
- CI_COVERAGE: 'true'
157
+ # Full coverage on each OS; 3.11 retains the same compatibility tests without duplicate instrumentation.
158
+ CI_COVERAGE: ${{ matrix.python == '3.14' }}
158
159
  # Keep temporary Git repositories beside the checkout on the work disk.
159
160
  TMP: ${{ runner.temp }}
160
161
  TEMP: ${{ runner.temp }}
@@ -186,7 +187,7 @@ jobs:
186
187
  python-version: '3.11'
187
188
  - run: uv pip install 'pytest==8.0.0'
188
189
  - name: Execute the same file plan on minimum pytest
189
- run: uv run --no-sync python scripts/run_ci_tests.py -q -n 4 --durations=20
190
+ run: uv run --no-sync pytest-selective execute-plan -- -q -n 4 --dist=worksteal --durations=20
190
191
  env:
191
192
  CI_PLANNER_RESULT: ${{ needs.select-tests.result }}
192
193
  CI_TEST_MODE: ${{ needs.select-tests.outputs.mode }}
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.2.0"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0](https://github.com/gigaverse-app/pytest-selective/compare/v1.1.2...v1.2.0) (2026-11-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * **ci:** share plan execution and reduce matrix instrumentation ([1fe0f36](https://github.com/gigaverse-app/pytest-selective/commit/1fe0f36463d4354a99ea3f45406825fa728a12c2))
9
+
3
10
  ## [1.1.2](https://github.com/gigaverse-app/pytest-selective/compare/v1.1.1...v1.1.2) (2026-11-09)
4
11
 
5
12
 
@@ -79,7 +79,9 @@ split indexes and linked worktrees without modifying the user's index or HEAD.
79
79
  CI tests Python 3.11 and 3.14 on Linux, Windows, and macOS. A separate job checks
80
80
  pytest 8.0.0 on Python 3.11, the plugin's lower bound. It also checks hooks, builds both distribution
81
81
  formats, validates PyPI metadata, and installs each distribution in an isolated
82
- environment on both Python versions. Evidence artifacts carry coverage and tutorial output on every platform and Python version.
82
+ environment on both Python versions. JUnit retains results and timings on every platform and Python version.
83
+ Full runs collect coverage on Python 3.14 on all three operating systems; Python 3.11 runs the same compatibility
84
+ tests without a duplicate instrumentation pass. Each runner uses four work-stealing workers.
83
85
 
84
86
  Use `uv lock --upgrade-package NAME` for a targeted dependency update and run the
85
87
  affected checks. Dependabot proposes weekly uv and GitHub Actions updates.
@@ -116,8 +118,8 @@ File planning derives its graph from that same lane config and merges its declar
116
118
  global execution inputs with the graph's environment inputs. Declare subprocess
117
119
  and non-import dependencies at their readers. Full plans collect whole-suite
118
120
  coverage; scoped plans produce JUnit timing evidence without presenting partial
119
- coverage as a whole-suite result. The small execution wrapper only consumes the
120
- public action's JSON file list: it does not implement another selector.
121
+ coverage as a whole-suite result. The public [shared-plan executor](docs/shared-plan-execution.md)
122
+ consumes the action's JSON file list without implementing another selector.
121
123
  The outer pytest process disables the selective plugin to avoid a second planner
122
124
  and a hidden dependency of every test on plugin startup. Plugin integration tests
123
125
  still load it normally in subprocesses and declare that dependency explicitly.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-selective
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: Explainable Python test impact analysis: select affected pytest files from Git changes
5
5
  Project-URL: Homepage, https://github.com/gigaverse-app/pytest-selective
6
6
  Project-URL: Documentation, https://pytest-selective.readthedocs.io/en/latest/
@@ -237,7 +237,7 @@ skip pytest, because invoking pytest without paths runs the whole suite.
237
237
  | --- | --- |
238
238
  | A transitive dependency | Import chains and package initialization |
239
239
  | A deleted or renamed module | Dependencies in both the base and head trees |
240
- | Fixtures or startup code | Ancestor conftests, plugins named by `-p` in `addopts`, and configured global modules |
240
+ | Fixtures or startup code | Ancestor conftests, plugins named by `-p` in `addopts`, the project's own `pytest11` entry points, and configured global modules |
241
241
  | Templates, schemas, or data files | [Reader-owned declarations and YAML sidecars](docs/tutorials/sidecar-dependencies.md) |
242
242
  | A surprisingly large selection | Dependency paths, startup-floor and blast-radius explanations |
243
243
  | A large CI suite | Cached parsing, graph reuse, and duration-based shard planning |
@@ -206,7 +206,7 @@ skip pytest, because invoking pytest without paths runs the whole suite.
206
206
  | --- | --- |
207
207
  | A transitive dependency | Import chains and package initialization |
208
208
  | A deleted or renamed module | Dependencies in both the base and head trees |
209
- | Fixtures or startup code | Ancestor conftests, plugins named by `-p` in `addopts`, and configured global modules |
209
+ | Fixtures or startup code | Ancestor conftests, plugins named by `-p` in `addopts`, the project's own `pytest11` entry points, and configured global modules |
210
210
  | Templates, schemas, or data files | [Reader-owned declarations and YAML sidecars](docs/tutorials/sidecar-dependencies.md) |
211
211
  | A surprisingly large selection | Dependency paths, startup-floor and blast-radius explanations |
212
212
  | A large CI suite | Cached parsing, graph reuse, and duration-based shard planning |
@@ -305,7 +305,8 @@ class RepositoryDriver(SelectionDriver):
305
305
  changed = set(git_text(self.root, "diff", "--name-only", "--no-renames", base, self.head).splitlines())
306
306
  tests = self._tests(self.head)
307
307
  at_head = self._runtime(self.head, tests)
308
- at_base = self._runtime(base, [test for test in tests if test in set(self._tests(base))])
308
+ base_tests = set(self._tests(base))
309
+ at_base = self._runtime(base, [test for test in tests if test in base_tests])
309
310
  needed = {test for test in tests if (at_head[test] | at_base.get(test, frozenset())) & changed}
310
311
  return {NAMES[test] for test in needed} - set(selection["selected"])
311
312
 
@@ -9,12 +9,16 @@ Modules and sys.path are restored between test files, so each file's dependencie
9
9
  import json
10
10
  import os
11
11
  import sys
12
+ from functools import cache
12
13
  from pathlib import Path
13
14
 
14
15
  import pytest
15
16
 
16
17
 
18
+ @cache
17
19
  def _under(root: Path, path: str | None) -> str | None:
20
+ # Each process inspects one immutable exported tree. Cache path resolution,
21
+ # never the per-test import/open observations, which remain isolated below.
18
22
  if not path:
19
23
  return None
20
24
  try:
@@ -97,7 +97,7 @@ patterns to your actual inventory. Central-rule reader patterns use shell-style
97
97
  matching: here `*` also covers nested paths under `tests/`. Common lockfiles, dependency manifests and pytest configuration already belong
98
98
  to the default `global_inputs`. Keep those defaults unless you have reviewed a
99
99
  narrower environment contract; these reader-specific rules are for additional
100
- inputs. Other non-Python files are not automatically global inputs. Plugins named by `-p NAME` in a pytest configuration's `addopts` are global modules automatically; plugins passed only on the command line or in `PYTEST_ADDOPTS` are not visible to the planner. Use `global_modules` for those and other modules loaded by every test and
100
+ inputs. Other non-Python files are not automatically global inputs. Plugins named by `-p NAME` in a pytest configuration's `addopts` are global modules automatically, and so are the project's own `pytest11` entry points (`[project.entry-points.pytest11]` or `[tool.poetry.plugins.pytest11]` in any tracked `pyproject.toml`) whose module is a tracked file. An entry point stays global unless every pytest configuration disables it, with `-p no:NAME` (`NAME` is the entry point's name) or `--disable-plugin-autoload` in `addopts`. Plugins passed only on the command line or in `PYTEST_ADDOPTS`, and `PYTEST_DISABLE_PLUGIN_AUTOLOAD`, are not visible to the planner. Use `global_modules` for those and other modules loaded by every test and
101
101
  `always_run` for explicit canary test paths. Retain full-suite checkpoints.
102
102
 
103
103
  ## Explain and audit
@@ -121,6 +121,49 @@ pytest-selective audit --config .github/test-impact.json --records impact-audit
121
121
  Audit results cover executed paths, not every possible execution. Declare missing
122
122
  edges at their readers. See [evidence and limitations](evidence.md).
123
123
 
124
+ ### Write the missing declarations
125
+
126
+ `audit --write-declarations` writes a rule for every undeclared read into the
127
+ reading test's sidecar, `<test>.gv-dependency.yaml`, creating it or appending to
128
+ the one it already has:
129
+
130
+ ```bash
131
+ pytest-selective audit --config .github/test-impact.json --records impact-audit --write-declarations
132
+ pytest-selective audit --config .github/test-impact.json --records impact-audit --working-tree # confirms nothing is left before you commit
133
+ ```
134
+
135
+ ```yaml
136
+ dependencies:
137
+ - inputs:
138
+ - "//examples/backgrounds/**"
139
+ - "//examples/gold.toml"
140
+ kind: content
141
+ reason: "Read at runtime; recorded by pytest-selective audit --write-declarations."
142
+ - inputs:
143
+ - "//scripts/replay_coverage.py"
144
+ kind: import
145
+ reason: "Loaded or run outside the test's imports; recorded by pytest-selective audit --write-declarations."
146
+ ```
147
+
148
+ - A Python input becomes an `import` rule: a test that runs a script in a
149
+ subprocess also depends on what the script imports. Any other input becomes a
150
+ `content` rule. Review the kind where the test only reads Python as text.
151
+ - Several reads in one directory become `<directory>/**` only when the test read
152
+ every tracked file under it, so the glob claims nothing the run did not see.
153
+ It also covers files added there later.
154
+ - Every pattern must match a tracked file at the audited revision. A path the
155
+ pattern language cannot express (for example one containing `[`) is printed as
156
+ `not declared` and the command exits 2.
157
+ - Appending keeps an existing sidecar's text and comments. When the result would
158
+ not parse to exactly the old rules plus the new ones, the file is left alone
159
+ and the rules are printed for you to add by hand.
160
+
161
+ The command prints each sidecar and pattern it wrote and exits 0 when every
162
+ undeclared read was declared. The files are in the working tree, so the plain
163
+ audit (which reads `--head`) still reports them until you commit; use
164
+ `--working-tree` to check first. Prefer a hand-written comment or directory
165
+ manifest where it reads better; the writer only removes the transcription step.
166
+
124
167
  Audit discovery uses the requested repository even when a calling Git hook exports
125
168
  location variables for another checkout. It shares repository isolation with the
126
169
  planner and expansion guard.
@@ -30,7 +30,8 @@ uv run pytest-selective audit --config .github/test-impact.json --records impact
30
30
  The recorder observes real file reads in the tests you execute. The audit can
31
31
  expose a JSON fixture, template or schema that the graph did not declare. Add an
32
32
  edge at the reader using a dependency comment or YAML sidecar, then recheck the
33
- route. [Work through the sidecar tutorial](tutorials/sidecar-dependencies.md), or consult
33
+ route. `audit --write-declarations` writes those sidecars for you; see
34
+ [write the missing declarations](configuration.md#write-the-missing-declarations). [Work through the sidecar tutorial](tutorials/sidecar-dependencies.md), or consult
34
35
  [declaration syntax](configuration.md#declare-inputs-that-imports-cannot-describe).
35
36
  The audit covers observed paths; keep representative full runs for paths it has
36
37
  not seen, and explicitly model dynamic imports and external inputs.
@@ -0,0 +1,43 @@
1
+ # Plan once, execute across compatibility lanes
2
+
3
+ Use `pytest-selective execute-plan` to run a file plan emitted by the public
4
+ test-impact action. Every matrix job consumes the same plan; it does not need
5
+ Git history or a second dependency analysis.
6
+
7
+ ```yaml
8
+ - run: uv run --locked pytest-selective execute-plan -- -q -n 4 --dist=worksteal
9
+ env:
10
+ CI_PLANNER_RESULT: ${{ needs.plan.result }}
11
+ CI_TEST_MODE: ${{ needs.plan.outputs.mode }}
12
+ CI_TEST_FILES: ${{ needs.plan.outputs.files }}
13
+ CI_COVERAGE: 'true'
14
+ ```
15
+
16
+ `files` must be the action's `test_roots_json` output. A successful `none` plan
17
+ does not start pytest. Failed planning, malformed JSON, unknown modes or an
18
+ empty scoped selection execute the complete roots. Roots default to `tests`;
19
+ override with `--full-roots tests/unit tests/integration -- ...`. Paths with
20
+ spaces remain single arguments, and pytest receives its normal exit status.
21
+
22
+ `CI_COVERAGE=true` adds `--cov --cov-report=term-missing --cov-report=xml` only
23
+ for full execution. The environment must have pytest-cov installed. Keep
24
+ coverage thresholds in your project configuration; do not enforce a full-suite
25
+ threshold on selected files. The outer execution disables the selective plugin
26
+ because planning already happened. Integration tests can still load it explicitly.
27
+
28
+ When lane and file selection share a graph, derive the file config:
29
+
30
+ ```sh
31
+ pytest-selective file-config --lanes .github/test-lanes.json --output /tmp/test-impact.json
32
+ ```
33
+
34
+ This combines the graph's global inputs with the lane config's explicit global
35
+ inputs. Declare `global_inputs` before using it. Lane-specific `inputs` remain
36
+ lane admission rules; they do not automatically make every test file affected.
37
+ Declare non-import dependencies in the graph as usual.
38
+
39
+ pytest-selective's own CI uses four work-stealing workers on each existing
40
+ runner. Its full checkpoints retain all Python 3.11 and 3.14 compatibility tests
41
+ on Linux, Windows and macOS. Coverage runs on Python 3.14 on all three systems,
42
+ avoiding a second instrumentation pass on Python 3.11. The minimum-pytest lane,
43
+ Quint contract and installed-distribution smoke remain independent checks.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-selective"
7
- version = "1.1.2"
7
+ version = "1.2.0"
8
8
  description = "Explainable Python test impact analysis: select affected pytest files from Git changes"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,15 +4,8 @@ import sys
4
4
  from pathlib import Path
5
5
 
6
6
  from pytest_selective.ci import LaneConfig
7
- from pytest_selective.impact_models import ImpactConfig
8
7
 
9
-
10
- def file_config(lanes: LaneConfig) -> ImpactConfig:
11
- if lanes.global_inputs is None:
12
- raise ValueError("This repository must declare its shared CI execution inputs")
13
- return lanes.graph.model_copy(
14
- update={"global_inputs": sorted(set(lanes.graph.global_inputs) | set(lanes.global_inputs))}
15
- )
8
+ file_config = LaneConfig.file_config
16
9
 
17
10
 
18
11
  if __name__ == "__main__":
@@ -0,0 +1,8 @@
1
+ """Compatibility entry point for the public shared-plan executor."""
2
+
3
+ import sys
4
+
5
+ from pytest_selective.execution import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main(["--", *sys.argv[1:]]))
@@ -55,6 +55,14 @@ class LaneConfig(ImpactModel):
55
55
  # including after JSON serialization. Explicit declarations always win.
56
56
  global_inputs: list[str] | None = None
57
57
 
58
+ def file_config(self) -> ImpactConfig:
59
+ """File selection shares the lane graph and explicit execution-wide inputs."""
60
+ if self.global_inputs is None:
61
+ raise ValueError("Declare global_inputs before deriving a file-selection config")
62
+ return self.graph.model_copy(
63
+ update={"global_inputs": sorted(set(self.graph.global_inputs) | set(self.global_inputs))}
64
+ )
65
+
58
66
  def is_global_input(self, path: str) -> bool:
59
67
  if self.global_inputs is not None:
60
68
  return matches(path, self.global_inputs)
@@ -1,12 +1,17 @@
1
1
  """Execute the public action's shared file plan; this consumer does no selection."""
2
2
 
3
+ import argparse
3
4
  import json
4
5
  import os
5
6
  import subprocess
6
7
  import sys
7
8
 
8
9
 
9
- def main() -> int:
10
+ def main(argv: list[str] | None = None) -> int:
11
+ parser = argparse.ArgumentParser(description=__doc__)
12
+ parser.add_argument("--full-roots", nargs="+", default=["tests"])
13
+ parser.add_argument("pytest_args", nargs=argparse.REMAINDER)
14
+ args = parser.parse_args(argv)
10
15
  mode = os.environ.get("CI_TEST_MODE")
11
16
  try:
12
17
  files = json.loads(os.environ.get("CI_TEST_FILES", "[]"))
@@ -24,7 +29,7 @@ def main() -> int:
24
29
  else:
25
30
  print("No affected test files; pytest was not started.")
26
31
  return 0
27
- roots = ["tests"] if mode == "full" else files
32
+ roots = args.full_roots if mode == "full" else files
28
33
  coverage = []
29
34
  if mode == "full" and os.environ.get("CI_COVERAGE") == "true":
30
35
  coverage = ["--cov", "--cov-report=term-missing", "--cov-report=xml"]
@@ -32,7 +37,17 @@ def main() -> int:
32
37
  return subprocess.run(
33
38
  # Planning already happened. Integration tests load the plugin in their
34
39
  # subprocesses; do not make it an implicit dependency of every CI test.
35
- [sys.executable, "-m", "pytest", "-p", "no:selective", *sys.argv[1:], *coverage, "--", *roots]
40
+ [
41
+ sys.executable,
42
+ "-m",
43
+ "pytest",
44
+ "-p",
45
+ "no:selective",
46
+ *(args.pytest_args[1:] if args.pytest_args[:1] == ["--"] else args.pytest_args),
47
+ *coverage,
48
+ "--",
49
+ *roots,
50
+ ]
36
51
  ).returncode
37
52
 
38
53
 
@@ -45,15 +45,20 @@ def _exempt_pairs(snapshot: Snapshot, exemptions: list[AuditExemption]) -> list[
45
45
  return pairs
46
46
 
47
47
 
48
- def audit(snapshot: Snapshot, records: list[AuditRecord], config: ImpactConfig) -> AuditReport:
49
- """Every recorded (test, input) pair the test's dependency closure does not contain."""
50
- exempt = _exempt_pairs(snapshot, config.audit_exemptions)
48
+ def tracked_reads(snapshot: Snapshot, records: list[AuditRecord]) -> dict[str, set[str]]:
49
+ """Each test file's recorded reads, across every process, of files tracked in the snapshot."""
51
50
  reads: dict[str, set[str]] = {}
52
51
  for record in records:
53
52
  for test, paths in record.inputs.items():
54
53
  reads.setdefault(test, set()).update(paths)
55
54
  # Only the committed tree can be declared against: a file absent from HEAD has no edge to add.
56
- reads = {test: paths & snapshot.files.keys() for test, paths in reads.items() if test in snapshot.files}
55
+ return {test: paths & snapshot.files.keys() for test, paths in reads.items() if test in snapshot.files}
56
+
57
+
58
+ def audit(snapshot: Snapshot, records: list[AuditRecord], config: ImpactConfig) -> AuditReport:
59
+ """Every recorded (test, input) pair the test's dependency closure does not contain."""
60
+ exempt = _exempt_pairs(snapshot, config.audit_exemptions)
61
+ reads = tracked_reads(snapshot, records)
57
62
  # An always-run test is selected by every change, so no input can skip it.
58
63
  always = {test for test in reads if matches(test, config.always_run)}
59
64
  undeclared: list[UndeclaredRead] = []
@@ -0,0 +1,148 @@
1
+ """
2
+ Write the declarations a read audit finds missing, as one sidecar per reading test file.
3
+
4
+ ``pytest-selective audit --write-declarations`` turns each undeclared ``test -> input`` read into a
5
+ rule in ``<test>.gv-dependency.yaml``, the reader-owned form that states the edge's kind. A Python
6
+ input becomes an ``import`` rule, because a test that loads or runs a script outside its imports
7
+ (often in a subprocess) also depends on what that script imports. Any other input becomes a
8
+ ``content`` rule. Reads under one directory collapse into ``<directory>/**`` only when the test read
9
+ every tracked file under it, so the glob claims no input the run did not see. Every pattern is
10
+ checked against the analysed tree before anything is written.
11
+ """
12
+
13
+ import json
14
+ from collections.abc import Iterable
15
+ from pathlib import Path, PurePosixPath
16
+
17
+ from pytest_selective.impact_audit import tracked_reads
18
+ from pytest_selective.impact_declarations import (
19
+ SIDECAR_SUFFIX,
20
+ DependencyDeclarationError,
21
+ match_inputs,
22
+ parse_manifest,
23
+ )
24
+ from pytest_selective.impact_models import (
25
+ AuditRecord,
26
+ AuditReport,
27
+ ImpactModel,
28
+ ManifestDependencyRule,
29
+ Snapshot,
30
+ )
31
+
32
+ CONTENT_REASON = "Read at runtime; recorded by pytest-selective audit --write-declarations."
33
+ IMPORT_REASON = "Loaded or run outside the test's imports; recorded by pytest-selective audit --write-declarations."
34
+
35
+
36
+ class SidecarDeclarations(ImpactModel):
37
+ """The rules one test file's sidecar gains, and the reads each pattern stands for."""
38
+
39
+ test: str
40
+ rules: list[ManifestDependencyRule]
41
+ #: Pattern (as written, ``//``-rooted) to the undeclared reads it declares.
42
+ covers: dict[str, list[str]]
43
+
44
+ @property
45
+ def sidecar(self) -> str:
46
+ return self.test + SIDECAR_SUFFIX
47
+
48
+
49
+ class DeclarationPlan(ImpactModel):
50
+ sidecars: list[SidecarDeclarations]
51
+ #: Reads no valid pattern can name (the path itself is not a valid declaration), with the reason.
52
+ skipped: list[str]
53
+
54
+
55
+ def _parents(path: str) -> Iterable[str]:
56
+ """Each directory above ``path``, nearest first, never the repository root."""
57
+ for parent in PurePosixPath(path).parents:
58
+ if parent.as_posix() != ".":
59
+ yield parent.as_posix()
60
+
61
+
62
+ def _patterns(undeclared: set[str], read: set[str], files: set[str]) -> dict[str, set[str]]:
63
+ """Group undeclared reads into patterns: a directory glob only when the test read all of it."""
64
+ patterns: dict[str, set[str]] = {}
65
+ for path in sorted(undeclared):
66
+ widest = None
67
+ for directory in _parents(path):
68
+ under = {file for file in files if file.startswith(directory + "/")}
69
+ if not under <= read:
70
+ # Every directory further up holds this one, so it cannot be fully read either.
71
+ break
72
+ if len(under & undeclared) > 1:
73
+ widest = directory
74
+ pattern = f"{widest}/**" if widest else path
75
+ patterns.setdefault(pattern, set()).add(path)
76
+ # Whether a directory qualifies does not depend on which read reached it, and every directory
77
+ # below a qualifying one is fully read too, so all reads under a glob chose that same glob.
78
+ return patterns
79
+
80
+
81
+ def plan_declarations(report: AuditReport, snapshot: Snapshot, records: list[AuditRecord]) -> DeclarationPlan:
82
+ """The sidecar rules that would declare every undeclared read in ``report``."""
83
+ files = set(snapshot.files)
84
+ reads = tracked_reads(snapshot, records)
85
+ undeclared: dict[str, set[str]] = {}
86
+ for read in report.undeclared:
87
+ undeclared.setdefault(read.test, set()).add(read.input)
88
+ sidecars: list[SidecarDeclarations] = []
89
+ skipped: list[str] = []
90
+ for test, paths in sorted(undeclared.items()):
91
+ inputs: dict[str, list[str]] = {"content": [], "import": []}
92
+ covers: dict[str, list[str]] = {}
93
+ for pattern, covered in sorted(_patterns(paths, reads[test], files).items()):
94
+ try:
95
+ # Never write a declaration that matches no tracked file at the analysed tree.
96
+ matched = match_inputs(pattern, files, f"{snapshot.tree}:{test}{SIDECAR_SUFFIX}")
97
+ except DependencyDeclarationError as error:
98
+ skipped.extend(f"{test} -> {path}: {error}" for path in sorted(covered))
99
+ continue
100
+ kind = "import" if any(path.endswith(".py") for path in matched) else "content"
101
+ inputs[kind].append(f"//{pattern}")
102
+ covers[f"//{pattern}"] = sorted(covered)
103
+ rules = [
104
+ ManifestDependencyRule(inputs=patterns, kind=kind, reason=reason)
105
+ for kind, reason in (("content", CONTENT_REASON), ("import", IMPORT_REASON))
106
+ if (patterns := inputs[kind])
107
+ ]
108
+ if rules:
109
+ sidecars.append(SidecarDeclarations(test=test, rules=rules, covers=covers))
110
+ return DeclarationPlan(sidecars=sidecars, skipped=skipped)
111
+
112
+
113
+ def render_rules(rules: list[ManifestDependencyRule]) -> str:
114
+ """Rules as items of a sidecar's ``dependencies`` list, every scalar quoted."""
115
+ lines: list[str] = []
116
+ for rule in rules:
117
+ lines.append(" - inputs:")
118
+ lines += [f" - {json.dumps(pattern)}" for pattern in rule.inputs]
119
+ lines.append(f" kind: {rule.kind}")
120
+ lines.append(f" reason: {json.dumps(rule.reason)}")
121
+ return "\n".join(lines) + "\n"
122
+
123
+
124
+ def write_sidecar(repo: Path, declarations: SidecarDeclarations) -> None:
125
+ """Create the test's sidecar, or append the rules to the one it already has."""
126
+ path = repo / declarations.sidecar
127
+ rendered = render_rules(declarations.rules)
128
+ if not path.exists():
129
+ # LF on every platform: the sidecar is committed beside the test.
130
+ text = "dependencies:\n" + rendered
131
+ parse_manifest(text.encode(), declarations.sidecar)
132
+ else:
133
+ existing = path.read_bytes().decode("utf-8-sig")
134
+ before = parse_manifest(existing.encode(), declarations.sidecar).dependencies
135
+ newline = "\r\n" if "\r\n" in existing else "\n"
136
+ text = existing + ("" if existing.endswith("\n") else newline) + rendered.replace("\n", newline)
137
+ # Appending is textual so the file keeps its comments; prove it parses to exactly the old
138
+ # rules plus the new ones, or leave the file untouched.
139
+ try:
140
+ appended = parse_manifest(text.encode(), declarations.sidecar).dependencies == before + declarations.rules
141
+ except DependencyDeclarationError:
142
+ appended = False
143
+ if not appended:
144
+ raise DependencyDeclarationError(
145
+ f"{declarations.sidecar}: cannot append to this sidecar's layout; add these rules by hand:\n{rendered}"
146
+ )
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ path.write_bytes(text.encode())
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
 
17
17
  from pytest_selective.checkpoint import full_checkpoint_reason
18
18
  from pytest_selective.impact_audit import audit, load_records, summary
19
+ from pytest_selective.impact_audit_declarations import plan_declarations, write_sidecar
19
20
  from pytest_selective.impact_explanation import dependent_tests, explain
20
21
  from pytest_selective.impact_graph import (
21
22
  DependencyDeclarationError,
@@ -27,10 +28,13 @@ from pytest_selective.impact_graph import (
27
28
  reachable,
28
29
  )
29
30
  from pytest_selective.impact_models import (
31
+ AuditRecord,
32
+ AuditReport,
30
33
  GraphAnalysisReport,
31
34
  ImpactConfig,
32
35
  ImpactReport,
33
36
  SelectionExplanation,
37
+ Snapshot,
34
38
  )
35
39
  from pytest_selective.impact_selection import affected, base_snapshot
36
40
  from pytest_selective.pytest_sharding import (
@@ -200,7 +204,9 @@ def _audit(args: argparse.Namespace, config: ImpactConfig, cache: GraphCache) ->
200
204
 
201
205
  A missing or unreadable record raises, which is operational (exit 1): the audit proved nothing.
202
206
  """
203
- report = audit(cache.snapshot(args.repo, args.head, config), load_records(args.records), config)
207
+ snapshot = cache.snapshot(args.repo, args.head, config)
208
+ records = load_records(args.records)
209
+ report = audit(snapshot, records, config)
204
210
  if args.output:
205
211
  args.output.parent.mkdir(parents=True, exist_ok=True)
206
212
  args.output.write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
@@ -215,9 +221,32 @@ def _audit(args: argparse.Namespace, config: ImpactConfig, cache: GraphCache) ->
215
221
  print(f"{read.test} -> {read.input}")
216
222
  for error in report.recorder_errors:
217
223
  print(f"recorder error: {error}", file=sys.stderr)
224
+ if args.write_declarations and report.undeclared:
225
+ return _write_declarations(args.repo, report, snapshot, records)
218
226
  return 2 if report.undeclared else 0
219
227
 
220
228
 
229
+ def _write_declarations(repo: Path, report: AuditReport, snapshot: Snapshot, records: list[AuditRecord]) -> int:
230
+ """Write a sidecar rule for every undeclared read; exit 2 only when some read could not be declared."""
231
+ plan = plan_declarations(report, snapshot, records)
232
+ for sidecar in plan.sidecars:
233
+ write_sidecar(repo, sidecar)
234
+ print(f"wrote {sidecar.sidecar}")
235
+ for rule in sidecar.rules:
236
+ for pattern in rule.inputs:
237
+ covered = sidecar.covers[pattern]
238
+ detail = f" ({len(covered)} reads)" if len(covered) > 1 else ""
239
+ print(f" {rule.kind}: {pattern}{detail}")
240
+ declared = sum(len(covered) for sidecar in plan.sidecars for covered in sidecar.covers.values())
241
+ print(
242
+ f"Declared {declared} of {len(report.undeclared)} undeclared reads in {len(plan.sidecars)} sidecars. "
243
+ "Review and commit them, then re-run audit (or audit --working-tree) to confirm."
244
+ )
245
+ for skipped in plan.skipped:
246
+ print(f"not declared: {skipped}", file=sys.stderr)
247
+ return 2 if plan.skipped else 0
248
+
249
+
221
250
  #: Files that joined the floor named in the budget error before the rest are counted.
222
251
  BUDGET_LISTED_FILES = 10
223
252
 
@@ -260,6 +289,20 @@ def _floor_budget(args: argparse.Namespace) -> int:
260
289
 
261
290
  def main(argv: list[str] | None = None) -> int:
262
291
  argv = list(sys.argv[1:] if argv is None else argv)
292
+ if argv[:1] == ["execute-plan"]:
293
+ from pytest_selective.execution import main as execute
294
+
295
+ return execute(argv[1:])
296
+ if argv[:1] == ["file-config"]:
297
+ from pytest_selective.ci import LaneConfig
298
+
299
+ config_parser = argparse.ArgumentParser(description="Derive file-selection config from explicit lane inputs")
300
+ config_parser.add_argument("--lanes", type=Path, required=True)
301
+ config_parser.add_argument("--output", type=Path, required=True)
302
+ config_args = config_parser.parse_args(argv[1:])
303
+ lanes = LaneConfig.model_validate_json(config_args.lanes.read_text(encoding="utf-8"))
304
+ config_args.output.write_text(lanes.file_config().model_dump_json(), encoding="utf-8")
305
+ return 0
263
306
  if argv[:1] == ["run"]:
264
307
  from pytest_selective.local import main as run_local
265
308
 
@@ -322,14 +365,26 @@ def main(argv: list[str] | None = None) -> int:
322
365
  # audit: the directory of pytest read records, and a markdown summary (GITHUB_STEP_SUMMARY) to append to.
323
366
  parser.add_argument("--records", type=Path, default=Path("impact-audit"))
324
367
  parser.add_argument("--summary", type=Path)
368
+ parser.add_argument(
369
+ "--write-declarations",
370
+ action="store_true",
371
+ help="audit: write a <test>.gv-dependency.yaml rule for every undeclared read",
372
+ )
325
373
  # floor-budget: the explanation `affected --explanation-output` wrote for this plan.
326
374
  parser.add_argument("--explanation", type=Path, default=Path("test-impact-explanation.json"))
327
375
  args = parser.parse_args(argv)
328
376
  full_reason = args.full
329
377
  args.full = full_reason is not None
330
378
  args.base = args.base or ("HEAD" if args.working_tree else "HEAD^")
331
- if args.working_tree and args.command not in {"affected", "warm", "dependencies", "explain", "conftest-impact"}:
332
- parser.error("--working-tree is supported for affected, warm, dependencies, explain and conftest-impact")
379
+ if args.working_tree and args.command not in {
380
+ "affected",
381
+ "audit",
382
+ "warm",
383
+ "dependencies",
384
+ "explain",
385
+ "conftest-impact",
386
+ }:
387
+ parser.error("--working-tree is supported for affected, audit, warm, dependencies, explain and conftest-impact")
333
388
  if args.command == "typecheck-scope":
334
389
  return _typecheck_scope(args)
335
390
  if args.command == "floor-budget":