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.
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/ci.yml +4 -3
- pytest_selective-1.2.0/.release-please-manifest.json +3 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/CHANGELOG.md +7 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/CONTRIBUTING.md +5 -3
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/PKG-INFO +2 -2
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/README.md +1 -1
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/conftest.py +2 -1
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/runtime_deps.py +4 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/configuration.md +44 -1
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/selection-health.md +2 -1
- pytest_selective-1.2.0/docs/shared-plan-execution.md +43 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/pyproject.toml +1 -1
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/ci_file_config.py +1 -8
- pytest_selective-1.2.0/scripts/run_ci_tests.py +8 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/ci.py +8 -0
- pytest_selective-1.1.2/scripts/run_ci_tests.py → pytest_selective-1.2.0/src/pytest_selective/execution.py +18 -3
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_audit.py +9 -4
- pytest_selective-1.2.0/src/pytest_selective/impact_audit_declarations.py +148 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_cli.py +58 -3
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_graph.py +142 -21
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_ci_admission.py +1 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_ci_test_execution.py +5 -5
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_repository_ci.py +28 -3
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_audit.py +34 -0
- pytest_selective-1.2.0/tests/testing/test_impact_audit_declarations.py +114 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_graph.py +69 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/uv.lock +1 -1
- pytest_selective-1.1.2/.release-please-manifest.json +0 -3
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.dry-lint/baseline.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.dry-lint/progress.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.dry-lint.toml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.gitattributes +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/dependabot.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/test-lanes.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/validation-inputs.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/docs.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/dry-lint-audit.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/dry-lint-judge.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/dry-lint.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/observe-ci.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/pr-title.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/public-actions.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/release-please.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/release.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.github/workflows/windows-performance.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.gitignore +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.pre-commit-config.yaml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/.readthedocs.yaml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/AGENTS.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/CLAUDE.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/LICENSE +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/PROVENANCE.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/RELEASING.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/action.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/README.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/plan-lanes/action.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/pytest-expansion/action.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/test-impact/action.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/actions/validation-gate/action.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/agentsync.toml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/README.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/quint-connect.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/quint.ini +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/selection.qnt +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/selection_driver.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/contracts/selection/test_selection_contract.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/README.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/actions.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/agents.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/banner.png +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/banner.svg +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/brand.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/icon.png +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/logo.svg +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/raster-sources.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/social-preview.png +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/assets/social-preview.svg +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-ci.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-docs-ci.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-docs-widget.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-due-widget.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-input-replays.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-obligation-docs.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-obligation.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-routing.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22-selective-docs.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-22.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/obligation-baseline.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-after-3.11.xml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-after-3.14.xml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-after.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-control-3.11.xml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-control-3.14.xml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-control.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor/selective-earlier.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/2026-10-23-windows-saleor.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/benchmarks/windows-runtime.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/ci-toolkit.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/ci.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/comparison-pytest-impacted.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/evidence.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/local-development.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/pytest-hygiene.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorial.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorials/adoption-checklist.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorials/sidecar-dependencies.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/tutorials/skip-unrelated-ci.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/actions-directory-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/codex-selector-correctness-performance-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/codex-windows-git-performance-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/graph-based-inputs-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/impacted-improvements-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/move-action-implementations-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/public-action-onboarding-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/ratchet-duplicates-zero-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/docs/work-in-progress/tickets/rerun-selection-test-plan.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/assets/routing-comparison.svg +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/check_site.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/ecosystem.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/hooks.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/index.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/llms.txt +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/overrides/main.html +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/publishing.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/requirements.in +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/documentation/requirements.txt +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/mkdocs.yml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/release-please-config.json +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/benchmark_graph.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/benchmark_parsing.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/benchmark_rust.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/check_docs.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/check_metadata.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/compare_impacted.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/measure_ci_runs.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/public_actions.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/render_brand.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/smoke_distribution.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/scripts/tutorial.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/skills/pytest-selective/SKILL.md +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/skills/pytest-selective/agents/openai.yaml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/__init__.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/__main__.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/ast_imports.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/checkpoint.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/gate.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/git_operations.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/hygiene.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_audit_plugin.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_declarations.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_explanation.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_first_party.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_models.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_name_scope.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/impact_selection.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/input_policy.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/local.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/parse_batch.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/plugin.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/py.typed +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/pytest_expansion.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/pytest_plugins.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/pytest_sharding.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/test_inventory.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/typecheck_scope.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/src/pytest_selective/working_tree.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/__init__.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_checkpoint_policy.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_ci_measurements.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_distribution_metadata.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_hygiene.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_public_actions.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_pytest_expansion.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_release_workflow.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_repository_hygiene.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_repository_hygiene.py.gv-dependency.yaml +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/test_tutorial.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/__init__.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/git_repo.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_ast_imports.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_ci.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_gate.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_git_repo.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_global_inputs.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_explanation.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_first_party.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_impact_name_scope.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_parse_batch.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_portability.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_pytest_plugin.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_pytest_plugins.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_pytest_sharding.py +0 -0
- {pytest_selective-1.1.2 → pytest_selective-1.2.0}/tests/testing/test_typecheck_scope.py +0 -0
- {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
|
|
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
|
-
|
|
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
|
|
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 }}
|
|
@@ -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.
|
|
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
|
|
120
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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__":
|
|
@@ -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 =
|
|
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
|
-
[
|
|
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
|
|
49
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
332
|
-
|
|
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":
|