From ce72843d3c7e5c2ed6d97955ed6ce3707acb784f Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 30 Sep 2026 17:12:17 +0000 Subject: [PATCH 1/5] refactor(changelog)!: adopt pinned shared changelog commands - Delegate generation, normalization, minor archiving, release notes and local tag preparation to the published research-repo-tools 0.1.7 CLI. - Preserve retained release history and dependency-note links while moving completed series to docs/archives/changelog. - Keep the historical git-cliff policy as a documented consumer exception and validate published changelog links alongside the shared checks. BREAKING CHANGE: changelog-unreleased now requires TAG DATE and leaves release metadata preparation separate. Replace the removed standalone archive-changelog, postprocess-changelog and tag-release entry points with research-repo-tools changelog commands. Tags must use v-prefixed SemVer; tagging requires today's UTC release date matching CITATION.cff. Refs #254 --- CHANGELOG.md | 16 +- CONTRIBUTING.md | 15 +- changelog-rumdl.toml | 7 + cliff.toml | 3 + docs/RELEASING.md | 18 +- docs/{archive => archives}/changelog/0.1.md | 0 docs/{archive => archives}/changelog/0.2.md | 1 + docs/{archive => archives}/changelog/0.3.md | 3 + docs/code_organization.md | 6 +- docs/dev/docs.md | 3 +- justfile | 74 +- pyproject.toml | 20 +- scripts/README.md | 74 +- scripts/archive_changelog.py | 596 ---------- scripts/check_docs_version_sync.py | 1 + scripts/postprocess_changelog.py | 1001 ---------------- scripts/tag_release.py | 436 ------- scripts/tests/test_archive_changelog.py | 715 ----------- scripts/tests/test_changelog_integration.py | 295 +++++ scripts/tests/test_check_docs_version_sync.py | 5 +- scripts/tests/test_postprocess_changelog.py | 1056 ----------------- scripts/tests/test_tag_release.py | 492 -------- semgrep.yaml | 1 + uv.lock | 44 + 24 files changed, 512 insertions(+), 4370 deletions(-) create mode 100644 changelog-rumdl.toml rename docs/{archive => archives}/changelog/0.1.md (100%) rename docs/{archive => archives}/changelog/0.2.md (99%) rename docs/{archive => archives}/changelog/0.3.md (99%) delete mode 100755 scripts/archive_changelog.py delete mode 100644 scripts/postprocess_changelog.py delete mode 100755 scripts/tag_release.py delete mode 100644 scripts/tests/test_archive_changelog.py create mode 100644 scripts/tests/test_changelog_integration.py delete mode 100644 scripts/tests/test_postprocess_changelog.py delete mode 100644 scripts/tests/test_tag_release.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b130384..1549423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exclude static-analysis fixtures from CodeRabbit review and leave docstring policy to Ruff. - [**breaking**] Harden rational APIs and release comparisons [`d8f9897`](https://github.com/acgetchell/la-stack/commit/d8f9897f59cce0a220efdbbcbaaf9180861889e9) + - canonicalize signed and unreduced rational inputs at construction boundaries - preserve invariant-bearing RationalVector solutions across both exact input domains - retain typed singularity, conversion, and runtime-dispatch diagnostics @@ -167,6 +168,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document runtime estimates, headroom, and pre-release verification. - Enforce benchmark setup limits and improve README navigation [`d8382ea`](https://github.com/acgetchell/la-stack/commit/d8382eac0b6b467ea390ea04a5ec04fb4600bc8a) + - Limit checkout to 2 minutes and share a 28-minute timeout across tool installation, input validation, and benchmark inventory. - Align budget documentation with the enforced setup limits. - Place scalar types, API navigation, and features after Quickstart, move Examples before Benchmarks, and update the Contents list. @@ -183,6 +185,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update release instructions to use --ref "$TAG" instead of main. - Correct interval bounds and support mutable dispatch captures [`3fb215b`](https://github.com/acgetchell/la-stack/commit/3fb215b6dcbc17d5ed209b4b35a6038d8e0362af) + - Use magnitude-ordered FastTwoSum to avoid spurious non-finite errors in interval addition and subtraction near f64::MAX. - Allow dimension-dispatch macro bodies to mutate captures while preserving support for consuming closures. - Skip redundant GCD work when clearing rational denominators. @@ -208,6 +211,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Publish allocation and timing data with provenance and reproduction steps supporting the existing borrowed implementation. - [**breaking**] Optimize exact conversion and dense 4D determinants [`7fd6d8e`](https://github.com/acgetchell/la-stack/commit/7fd6d8efd3cf6c12be5a591d87addd6e4961fa82) + - Avoid redundant fraction reduction in strict RationalVector conversion. - Share minors in dense exact 4ร—4 determinants while preserving the sparse fast path. - Add adversarial solve benchmarks across D=2,3,4,5,8,16,32,64 and exact-arithmetic diagnostics. @@ -265,6 +269,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document exact matrix/RHS scaling and the power-of-two factor that preserves linear systems. - [**breaking**] Unify local and release performance workflows [`c601c41`](https://github.com/acgetchell/la-stack/commit/c601c4146b01868a29288b7221e413706e3e01c7) + - Retain local Markdown, CSV, and provenance artifacts while allowing same-version comparisons of tracked changes. - Exclude untracked files explicitly and isolate the narrowed non-exact comparison bundle. - Promote distinct-release artifacts through performance-doc and keep performance-release atomic and rollback-capable. @@ -306,6 +311,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [setuptools](https://github.com/pypa/setuptools) to permit the latest version. Updates `ruff` from 0.16.1 to 0.16.2 + - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2) @@ -643,6 +649,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Revalidate finite proof conversions [`419a90f`](https://github.com/acgetchell/la-stack/commit/419a90f7267608051736498154ac5e6faf0909c5) Ensure internal finite proof conversions cannot accept raw Matrix or Vector storage without checking the invariant. + - Revalidate TryFrom<Matrix<D>> and TryFrom<Vector<D>> before constructing finite wrappers. - Measure exact random percentile benchmarks over repeated corpus timings and cumulative input sets. - Tighten Codecov status thresholds and extend benchmark workflow timeout. @@ -796,6 +803,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 APIs to catch tail cases that fixed well-conditioned inputs miss. Benchmarks (benches/exact.rs): + - Factor out `bench_extreme_group` helper running the same four benches (`det_sign_exact`, `det_exact`, `solve_exact`, `solve_exact_f64`) so adversarial groups are directly comparable. @@ -1066,6 +1074,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Replace `BigRational::from_float(x)` in `f64_to_bigrational` with manual IEEE 754 binary64 bit decomposition and `BigRational::new_raw`, bypassing the unnecessary GCD normalization that `from_float` performs internally. + - Decompose f64 into sign, biased exponent, and significand fields - Strip trailing zeros from the significand so the fraction is already in lowest terms (odd numerator over power-of-two denominator) @@ -1081,6 +1090,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 all f64 entries are decomposed into mantissa ร— 2^exponent, scaled to a common integer base, and eliminated without any rational arithmetic. The result is reconstructed as BigRational only at the end. + - Add f64_decompose helper (extracted from f64_to_bigrational) - Add bareiss_det_int: integer-only Bareiss returning (BigInt, i32) - Add bigint_exp_to_bigrational: reconstruction with trailing-zero @@ -1105,9 +1115,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Older releases are archived by minor series: -- [0.3.x](docs/archive/changelog/0.3.md) -- [0.2.x](docs/archive/changelog/0.2.md) -- [0.1.x](docs/archive/changelog/0.1.md) +- [0.3.x](docs/archives/changelog/0.3.md) +- [0.2.x](docs/archives/changelog/0.2.md) +- [0.1.x](docs/archives/changelog/0.1.md) [0.4.6]: https://github.com/acgetchell/la-stack/compare/v0.4.5...v0.4.6 [0.4.5]: https://github.com/acgetchell/la-stack/compare/v0.4.4...v0.4.5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7cce9c..271a36f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ clarity, and the fixed-dimension stack-allocation model. Install Rust 1.98.1 through [rustup](https://rustup.rs/), Git, the [GitHub CLI](https://cli.github.com/), Python 3.14, -[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Authenticate the GitHub +[`uv` 0.12.10](https://docs.astral.sh/uv/), and `jq`. Authenticate the GitHub CLI for repository operations, then install the repository's pinned `just` version from its locked dependency graph: @@ -27,6 +27,19 @@ just ci # run the comprehensive local CI path Use `just fix` when you intentionally want formatters and automatic fixes to change files. Run `just --list` for the full command surface. +Changelog commands use the published `research-repo-tools==0.1.7` package, +locked in the `tooling` dependency group and included by `dev`. Normal setup +and CI install it from PyPI through `uv sync --locked --group dev`; a sibling +checkout is unnecessary. To upgrade it deliberately, review the exact +`tooling` requirement and refresh `uv.lock` together, then run the consumer +integration tests and `just ci`. See the [Scripts guide](scripts/README.md#changelog-and-release-tooling) +for the retained changelog policy and ownership boundary. + +This first adoption phase leaves the existing setup and dependency-update +recipes in place. The shared setup contract requires an existing uv and uses +`research-repo-tools setup`; it does not generate bootstrap installers. +Full toolchain and release-metadata adoption are separate follow-ups. + Use `just update` for deliberate dependency and tool maintenance. It composes `just update-dependencies`, which advances Cargo dependency requirements, exact Python development-tool pins, and the Cargo/uv locks, with diff --git a/changelog-rumdl.toml b/changelog-rumdl.toml new file mode 100644 index 0000000..9f8b77a --- /dev/null +++ b/changelog-rumdl.toml @@ -0,0 +1,7 @@ +# Inherit repository Markdown rules for candidates before atomic publication. +extends = "pyproject.toml" + +[global] +# Archive links can point to candidates that have not been published yet. +# The normal repository Markdown check still validates their final destinations. +extend-disable = [ "MD057" ] diff --git a/cliff.toml b/cliff.toml index 9c64064..9f8f6ce 100644 --- a/cliff.toml +++ b/cliff.toml @@ -1,5 +1,8 @@ # git-cliff configuration for la-stack # https://git-cliff.org/docs/configuration +# Consumer-owned exception to the packaged policy: preserve historical +# chore(deps-dev) bodies and their upstream release-note/changelog links. +# See scripts/README.md for the migration boundary. [changelog] # Keep a Changelog header diff --git a/docs/RELEASING.md b/docs/RELEASING.md index d181c6a..6d8703c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -93,13 +93,21 @@ continuing. ### 3. Generate the release changelog ```bash -just changelog-unreleased "$TAG" +RELEASE_DATE="$(date -u +%F)" +just changelog-unreleased "$TAG" "$RELEASE_DATE" ``` This generates `CHANGELOG.md` as though the target tag already existed, archives -completed minor series under `docs/archive/changelog/`, and synchronizes the -changelog heading with the UTC preparation date recorded in `CITATION.cff`. -Review the generated changelog and any archive changes. +completed minor series under `docs/archives/changelog/`, and uses the explicit +ISO release date. Verify that `RELEASE_DATE` equals the date recorded in +`CITATION.cff` by metadata preparation; if preparation crossed UTC midnight, +rerun `just update-version "$TAG"` first. Generation preserves existing release +dates and does not update package or citation metadata. +Use `just changelog-preview --tag "$TAG" --date "$RELEASE_DATE"` for a read-only +preview, and `just changelog-check` to validate the root and every archive. +Review the generated changelog and any archive changes. The +[Scripts guide](../scripts/README.md#changelog-and-release-tooling) explains +the consumer policy exception that preserves historical dependency-note links. ### 4. Generate the release performance comparison @@ -189,7 +197,7 @@ focused release preparation without feature work. ### Handling fixes found during preparation For a critical fix that must be included, make and commit the fix, rerun -`just changelog-unreleased "$TAG"`, review and stage only the resulting changelog +`just changelog-unreleased "$TAG" "$RELEASE_DATE"`, review and stage only the resulting changelog files, and commit that generated update separately. For a non-critical fix, file an issue and defer it to a later release. Do not diff --git a/docs/archive/changelog/0.1.md b/docs/archives/changelog/0.1.md similarity index 100% rename from docs/archive/changelog/0.1.md rename to docs/archives/changelog/0.1.md diff --git a/docs/archive/changelog/0.2.md b/docs/archives/changelog/0.2.md similarity index 99% rename from docs/archive/changelog/0.2.md rename to docs/archives/changelog/0.2.md index fb89834..202f02c 100644 --- a/docs/archive/changelog/0.2.md +++ b/docs/archives/changelog/0.2.md @@ -200,6 +200,7 @@ used in delaunay. New files: + - cliff.toml: Keep a Changelog template with commit parsers for mixed commit history, PR/commit link rewriting via preprocessors - scripts/tag_release.py: CLI for annotated tag creation with semver diff --git a/docs/archive/changelog/0.3.md b/docs/archives/changelog/0.3.md similarity index 99% rename from docs/archive/changelog/0.3.md rename to docs/archives/changelog/0.3.md index baf6d16..5badd2c 100644 --- a/docs/archive/changelog/0.3.md +++ b/docs/archives/changelog/0.3.md @@ -22,6 +22,7 @@ - Update docs: README, AGENTS.md, REFERENCES.md, module doc, typos.toml - [**breaking**] Expose exact determinant value (det_exact, det_exact_f64) [`92ce476`](https://github.com/acgetchell/la-stack/commit/92ce476201d5759766d73221612cd27492bccbe5) + - Add `det_exact()` returning `BigRational` via Bareiss elimination - Add `det_exact_f64()` converting exact result to f64 - Add `LaError::Overflow` for f64 conversion overflow @@ -31,6 +32,7 @@ - Macro-generate exact tests for D=2..5 - Update README, AGENTS.md, REFERENCES.md, typos.toml - Exact linear system solve (solve_exact, solve_exact_f64) [`d04fcd3`](https://github.com/acgetchell/la-stack/commit/d04fcd3b24843b7377a34849dbb2e6469bf9fc56) + - Add `solve_exact()` returning `[BigRational; D]` via Gaussian elimination with partial pivoting in BigRational - Add `solve_exact_f64()` converting exact result to `Vector<D>` @@ -83,6 +85,7 @@ - Use idiomatic (0..D).rev() for back-substitution loop - Add LDLT example, solve_exact README snippet, and sync examples [`2bddccf`](https://github.com/acgetchell/la-stack/commit/2bddccf3f20156fadb4fee6a4093e76b1b631de1) + - Add examples/ldlt_solve_3x3.rs: 3ร—3 SPD tridiagonal LDLT solve + det - Add solve_exact_f64 code example to README exact arithmetic section - Add missing examples to justfile (ldlt_solve_3x3, exact_det_3x3, diff --git a/docs/code_organization.md b/docs/code_organization.md index e440f29..f987555 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -108,7 +108,11 @@ inherit the root workspace lints and share the lockfile and target directory. The [Benchmarking guide](BENCHMARKING.md) owns benchmark commands, methodology, baselines, output locations, and report promotion. The [Scripts guide](../scripts/README.md) owns the Python script inventory and entry points for comparisons, plotting, -release metadata, changelog generation/archiving, and tag preparation. +and release metadata. The pinned published `research-repo-tools` dependency +owns changelog generation, normalization, minor-series archiving, note lookup, +and tag preparation through its CLI. Consumer policy stays in `cliff.toml`, +`changelog-rumdl.toml`, and `[tool.research-repo-tools]` in `pyproject.toml`; +focused integration checks live in `scripts/tests/test_changelog_integration.py`. The [justfile](../justfile) owns executable development workflows. `scripts/release_baseline.py` owns release-suite inventory and complete raw diff --git a/docs/dev/docs.md b/docs/dev/docs.md index ff22738..6a6b76c 100644 --- a/docs/dev/docs.md +++ b/docs/dev/docs.md @@ -123,7 +123,8 @@ dependency snippets synchronized with `Cargo.toml` and follow version: crates.io documentation changes also require a new version. Never edit `CHANGELOG.md` directly. `just changelog` generates, post-processes, -archives, and formats the changelog; `just changelog-unreleased ` +archives, and formats the changelog through the pinned shared CLI; +`just changelog-unreleased ` prepends unreleased changes. Commit-message guidance lives in [Git and GitHub guidance](MANAGING_CHANGES.md). Benchmark report and generated asset ownership belongs in [Benchmarking](../BENCHMARKING.md). diff --git a/justfile b/justfile index 2c70c07..ac7e917 100644 --- a/justfile +++ b/justfile @@ -361,44 +361,33 @@ build-release: cargo-lock-check: cargo metadata --locked --format-version 1 --no-deps > /dev/null -# Changelog generation (git-cliff + post-processing + archiving) +# Generate, normalize, and rotate completed minor series with the pinned shared CLI. changelog: _ensure-git-cliff _ensure-rumdl python-sync + uv run --locked --group dev research-repo-tools changelog generate + +# Rotate existing history without regenerating release notes. +changelog-archive: python-sync + uv run --locked --group dev research-repo-tools changelog archive + +# Check release headings and all archives without writing files. +changelog-check: _ensure-rumdl python-sync #!/usr/bin/env bash set -euo pipefail - GIT_CLIFF_OFFLINE=true git-cliff -o CHANGELOG.md - uv run --locked postprocess-changelog - uv run --locked archive-changelog - archive_files=() - if [ -d docs/archive/changelog ]; then - while IFS= read -r -d '' file; do - archive_files+=("$file") - done < <(find docs/archive/changelog -name '*.md' -print0) - fi - if ((${#archive_files[@]})); then - rumdl fmt --silent CHANGELOG.md "${archive_files[@]}" - else - rumdl fmt --silent CHANGELOG.md - fi + shopt -s nullglob + uv run --locked --group dev research-repo-tools changelog check + rumdl check --no-cache --config pyproject.toml CHANGELOG.md docs/archives/changelog/*.md -# Prepend unreleased changes to CHANGELOG.md for the given version -changelog-unreleased version: _ensure-git-cliff _ensure-rumdl python-sync +# Validate generation and print the root changelog without publishing candidates. +changelog-preview *args: _ensure-git-cliff _ensure-rumdl python-sync #!/usr/bin/env bash set -euo pipefail - GIT_CLIFF_OFFLINE=true git-cliff --tag {{ quote(version) }} -o CHANGELOG.md - uv run --locked postprocess-changelog - uv run --locked archive-changelog - uv run --locked update-release-version {{ quote(version) }} --sync-changelog-date - archive_files=() - if [ -d docs/archive/changelog ]; then - while IFS= read -r -d '' file; do - archive_files+=("$file") - done < <(find docs/archive/changelog -name '*.md' -print0) - fi - if ((${#archive_files[@]})); then - rumdl fmt --silent CHANGELOG.md "${archive_files[@]}" - else - rumdl fmt --silent CHANGELOG.md - fi + uv run --locked --group dev research-repo-tools changelog generate --dry-run "$@" + +# Generate a prospective release using the explicit ISO date, without updating metadata. +changelog-release tag date: _ensure-git-cliff _ensure-rumdl python-sync + uv run --locked --group dev research-repo-tools changelog generate --tag {{ quote(tag) }} --date {{ quote(date) }} + +alias changelog-unreleased := changelog-release # Check (non-mutating): run all linters/validators check: lint @@ -411,7 +400,7 @@ check-fast: # CI simulation: flat GitHub-equivalent union of leaf validators. # Keep this dependency list explicit so each validation surface runs once without # re-entering broad check/test bundles. All Cargo targets match the SARIF lint scope. -ci: action-lint zizmor markdown-check spell-check docs-version-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-fixture-lint python-typecheck test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples +ci: action-lint zizmor markdown-check spell-check docs-version-check changelog-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-fixture-lint python-typecheck test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples @echo "๐ŸŽฏ CI checks complete!" # Validate CITATION.cff against the Citation File Format schema. @@ -537,7 +526,12 @@ help-workflows: @echo "" @echo "Changelog & releases:" @echo " just changelog # Regenerate CHANGELOG.md from full history" - @echo " just changelog-unreleased # Prepend unreleased changes for a version" + @echo " just changelog-preview # Preview generation without writing" + @echo " just changelog-release # Generate a release with an explicit date" + @echo " just changelog-unreleased # Alias for changelog-release" + @echo " just changelog-archive # Rotate existing changelog history" + @echo " just changelog-check # Validate root and archived release notes" + @echo " just release-notes # Print root or archived release notes" @echo " just tag # Create annotated tag from CHANGELOG.md" @echo " just tag-force # Recreate an existing tag" @echo " just update-version # Update release metadata and infer the previous tag" @@ -577,7 +571,7 @@ lint-code: rust-core-check python-check shell-check lint-config: json-check toml-ci yaml-ci github-actions-check justfile-fmt-check -lint-docs: markdown-ci docs-version-check +lint-docs: markdown-ci docs-version-check changelog-check # Markdown markdown-check: _ensure-rumdl _ensure-uv @@ -727,6 +721,10 @@ python-sync: _ensure-uv python-typecheck: python-sync uv run --locked ty check scripts/ tests/semgrep/scripts/ --error all +# Print release notes from the root changelog or a completed minor archive. +release-notes tag: python-sync + uv run --locked --group dev research-repo-tools changelog notes {{ quote(tag) }} + rust-core-check: cargo-lock-check fmt-check clippy-core doc-check semgrep semgrep-test unused-deps @echo "โœ… Rust core checks complete!" @@ -972,11 +970,11 @@ spell-check: _ensure-typos # Create an annotated git tag from the CHANGELOG.md section for the given version tag version: python-sync - uv run --locked tag-release {{ quote(version) }} + uv run --locked --group dev research-repo-tools changelog tag {{ quote(version) }} -# Recreate an existing tag (delete + recreate) +# Replace an existing local tag only after validating the release notes. tag-force version: python-sync - uv run --locked tag-release {{ quote(version) }} --force + uv run --locked --group dev research-repo-tools changelog tag {{ quote(version) }} --force # Testing: runnable Rust tests use nextest; rustdoc doctests remain on cargo test. test: test-lib test-doc diff --git a/pyproject.toml b/pyproject.toml index 69294c7..8fea857 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,12 +34,9 @@ dependencies = [ ] "Bug Tracker" = "https://github.com/acgetchell/la-stack/issues" [project.scripts] -archive-changelog = "archive_changelog:main" archive-performance = "archive_performance:main" bench-compare = "bench_compare:main" criterion-dim-plot = "criterion_dim_plot:main" -postprocess-changelog = "postprocess_changelog:main" -tag-release = "tag_release:main" check-docs-version-sync = "check_docs_version_sync:main" update-release-version = "update_release_version:main" update-cargo-tool-pins = "update_cargo_tool_pins:main" @@ -48,7 +45,7 @@ update-python-dev-pins = "update_python_dev_pins:main" # Configure setuptools to find modules in scripts/ directory. [tool.setuptools] package-dir = { "" = "scripts" } -py-modules = [ "archive_changelog", "archive_performance", "bench_compare", "benchmark_contract", "benchmark_summaries", "check_docs_version_sync", "check_semgrep_fixtures", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", "postprocess_changelog", "subprocess_utils", "tag_release", "update_cargo_tool_pins", "update_python_dev_pins", "update_release_version" ] +py-modules = [ "archive_performance", "bench_compare", "benchmark_contract", "benchmark_summaries", "check_docs_version_sync", "check_semgrep_fixtures", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", "subprocess_utils", "update_cargo_tool_pins", "update_python_dev_pins", "update_release_version" ] [tool.ruff] line-length = 160 @@ -92,7 +89,6 @@ max-complexity = 10 [tool.ruff.lint.isort] known-first-party = [ - "archive_changelog", "archive_performance", "bench_compare", "benchmark_contract", @@ -101,9 +97,7 @@ known-first-party = [ "criterion_dim_plot", "criterion_measurements", "performance_artifacts", - "postprocess_changelog", "subprocess_utils", - "tag_release", "update_cargo_tool_pins", "update_python_dev_pins", ] @@ -153,7 +147,9 @@ line-length = 160 package = true [dependency-groups] +tooling = [ "research-repo-tools==0.1.7" ] dev = [ + { include-group = "tooling" }, "actionlint-py==1.7.12.24", "pytest==9.1.1", "ruff==0.16.6", @@ -163,3 +159,13 @@ dev = [ "ty==0.0.79", "yamllint==1.38.0", ] + +[tool.research-repo-tools] +schema = 1 + +[tool.research-repo-tools.changelog] +owner = "acgetchell" +repository = "la-stack" +formatter = "changelog-rumdl.toml" +# Historical deps-dev bodies contain release-note links that the packaged policy omits. +cliff-config = "cliff.toml" diff --git a/scripts/README.md b/scripts/README.md index 31c316f..ae8c022 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -272,18 +272,58 @@ the citation date. # Full regeneration from all history just changelog -# Prepend only unreleased changes for a new version -just changelog-unreleased vX.Y.Z +# Preview without replacing the root or archive files +just changelog-preview + +# Generate a prospective release with an explicit ISO date +just changelog-unreleased vX.Y.Z YYYY-MM-DD +# Equivalent name: +just changelog-release vX.Y.Z YYYY-MM-DD + +# Rotate existing notes without regenerating Git history +just changelog-archive +just changelog-check +just release-notes vX.Y.Z ``` -`just changelog` runs `git-cliff -o CHANGELOG.md`, strips trailing blank -lines, archives completed changelog series, and formats the generated Markdown. -Configuration lives in `cliff.toml` at the repo root. The Breaking Changes -summary preserves full `BREAKING CHANGE` footer descriptions, including compiler -requirements and migration instructions; commits marked only with `!` fall back -to their subject. Post-processing escapes HTML in breaking-description prose -while preserving literal Rust syntax in Markdown code spans and fenced blocks, -and adds merged-PR summaries without replacing those descriptions. +The exact published `research-repo-tools==0.1.7` dependency is in `tooling`, +included by `dev`, and resolved from PyPI in `uv.lock`. Thin Just recipes use +the documented `research-repo-tools changelog` CLI. Its implementation and +common regressions belong to the shared package; this repository owns the +configuration, recipes, and `scripts/tests/test_changelog_integration.py`. +Internal shared modules are not a supported consumer API. + +`just changelog` generates, normalizes, formats, and rotates completed minor +series in one operation. It retains Unreleased and the newest minor series in +`CHANGELOG.md`, with older releases in `docs/archives/changelog/MAJOR.MINOR.md`. +Existing dated headings remain authoritative. Prospective generation requires +a `v`-prefixed SemVer tag and an explicit ISO date, and leaves package and +citation metadata untouched. Prepare metadata separately with +`just update-version`; choose the same date recorded in `CITATION.cff`. +Preview validates root and archive candidates without publishing them. +Malformed versions/dates, duplicate releases, conflicting retained notes, +and formatter failures stop publication with diagnostics. + +`cliff.toml` remains a deliberate consumer policy exception: the packaged +policy newly groups historical `chore(deps-dev)` entries as Dependencies and +omits their authored bodies. Those bodies contain retained Ruff, Ty, and +setuptools release-note, changelog, and comparison links. Retaining the existing +policy preserves this history and its original code-span representation. +The shared normalizer preserves complete breaking-change descriptions, Markdown +links, and literal code in the input while adding merged-PR summaries. +It retains embedded conventional headings that the old normalizer deduplicated. + +`changelog-rumdl.toml` inherits the repository Markdown policy and disables +MD057 only while formatting unpublished candidates, whose future archive paths +do not yet exist. Repository Markdown checks and focused archive-link checks +still validate published destinations. The archive move preserves all release +dates and links; generated whitespace changes align existing series with the +shared formatter so later generation remains conflict-free. + +The current local setup, release-metadata, dependency-update, scientific, +benchmark, and performance tooling remains consumer-owned. Shared toolchain +setup, updates, and other maintenance adoption belong in later PRs. No +notebook or review-tool migration is included here. ### Creating a release tag @@ -292,15 +332,19 @@ just tag vX.Y.Z # create an annotated tag matching Cargo.toml just tag-force vX.Y.Z # replace that tag only when explicitly repairing it ``` -The `tag-release` CLI (in `tag_release.py`) extracts the matching version -section from `CHANGELOG.md`, requires the tag to match the Cargo package version, -validates SemVer, and handles GitHub's 125KB tag-annotation size limit. +These recipes call `research-repo-tools changelog tag`. It searches the root +and canonical archives, validates the entire history, and requires the +`v`-prefixed tag to match the Cargo package version. The dated release heading +must equal today's UTC date and any `CITATION.cff` date. Oversized annotations +link to the full notes to respect GitHub's 125KB limit. `--force` replaces an +existing local ref only after validation; it does not first delete the old tag. +Tagging never pushes or publishes a release. Use the CLI's `--dry-run` to +preview an annotation without creating a tag. ### Scripts overview | Script | Purpose | |---|---| -| `archive_changelog.py` | Split completed changelog minor series into archives | | `archive_performance.py` | Promote release performance docs and archive older comparisons | | `performance_artifacts.py` | Validate and publish schema-versioned performance-comparison CSV/JSON inputs | | `bench_compare.py` | Compare Criterion benchmark baselines and render Markdown reports | @@ -308,8 +352,6 @@ validates SemVer, and handles GitHub's 125KB tag-annotation size limit. | `check_docs_version_sync.py` | Verify versioned documentation links and snippets stay synchronized | | `criterion_dim_plot.py` | Plot Criterion benchmark results (CSV + SVG + README table) | | `criterion_measurements.py` | Validate full Criterion sampling and estimates for local summaries and hosted archives | -| `tag_release.py` | Create annotated git tags from CHANGELOG.md sections | -| `postprocess_changelog.py` | Normalize and reflow generated git-cliff Markdown safely | | `release_baseline.py` | Inventory full Criterion suites and validate complete raw release baselines before packaging | | `subprocess_utils.py` | Safe subprocess wrappers for git commands | | `update_cargo_tool_pins.py` | Reconcile repository-owned Cargo and active uv tool pins with installed versions | diff --git a/scripts/archive_changelog.py b/scripts/archive_changelog.py deleted file mode 100755 index 1da184b..0000000 --- a/scripts/archive_changelog.py +++ /dev/null @@ -1,596 +0,0 @@ -#!/usr/bin/env -S uv run -"""Archive completed minor series from CHANGELOG.md into per-minor files. - -Parses the full CHANGELOG.md (produced by git-cliff + postprocess-changelog) -into version blocks, groups them by minor series (X.Y), and writes: - - - ``docs/archive/changelog/X.Y.md`` for each completed minor series - - A trimmed ``CHANGELOG.md`` containing only the preamble, Unreleased, - the active minor series, and an Archives link section - -The active minor is detected from the first tagged release heading after -Unreleased. All other minors are archived. - -Usage: - archive-changelog # default: CHANGELOG.md - archive-changelog path/to/CHANGELOG.md - archive-changelog --archive-dir docs/archive/changelog -""" - -import argparse -import logging -import os -import re -import sys -import tempfile -from itertools import pairwise -from pathlib import Path - -from postprocess_changelog import normalize_entry_headings_text, postprocess_text - -# Matches ``## [X.Y.Z]`` or ``## [Unreleased]`` -_VERSION_HEADING_RE = re.compile(r"^## \[") -_UNRELEASED_HEADING_RE = re.compile(r"^## \[Unreleased\](?:\s|$)") - -# Extracts a strict SemVer 2.0.0 version from a ``## [X.Y.Z]`` heading. -_SEMVER_ALNUM_ID = r"(?:(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z-]+)" -_SEMVER_PATTERN = ( - r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" - rf"(?:-(?:(?:0|[1-9]\d*)|{_SEMVER_ALNUM_ID})(?:\.(?:(?:0|[1-9]\d*)|{_SEMVER_ALNUM_ID}))*)?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" -) -_VERSION_RE = re.compile(rf"^## \[({_SEMVER_PATTERN})\](?:\s|$)") - -# Matches a reference-style link definition: ``[label]: URL`` -_LINK_DEF_RE = re.compile(r"^\[([^\]]+)\]:\s+\S+") - -# Archive directory relative to the repository root. -_DEFAULT_ARCHIVE_DIR = "docs/archive/changelog" - -LOGGER = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Parsing helpers -# --------------------------------------------------------------------------- - - -def _minor_key(version: str) -> str: - """Return the ``X.Y`` minor key for a semver version string. - - Parameters: - version: A version string like ``0.7.2`` or ``1.2.3-rc.1``. - - Returns: - The first two numeric components joined by a dot (e.g. ``0.7``). - - Raises: - ValueError: If *version* does not contain at least two dot-separated components. - """ - parts = version.split(".") - if len(parts) < 2: - msg = f"Expected a version with at least two components (X.Y), got: {version!r}" - raise ValueError(msg) - return f"{parts[0]}.{parts[1]}" - - -def _version_sort_key(label: str) -> tuple[bool, tuple[int, ...], tuple[tuple[int, int | str], ...]]: - """Return a sort key for a version label that orders by semantic version. - - Non-numeric labels (e.g. ``unreleased``) sort after all numeric versions. - Numeric parts are compared as integers so that ``0.10`` sorts after ``0.9``. - - Parameters: - label: A version label like ``0.7.2``, ``0.10``, or ``unreleased``. - - Returns: - A tuple suitable for use as a sort key. - """ - label_without_build = label.split("+", 1)[0] - core, separator, prerelease = label_without_build.partition("-") - parts = core.split(".") - try: - nums = tuple(int(p) for p in parts) - except ValueError: - # Non-numeric labels ("unreleased") sort last (True > False). - return (True, (), ()) - - if not separator: - prerelease_key: tuple[tuple[int, int | str], ...] = ((2, ""),) - else: - prerelease_key = tuple((0, int(part)) if part.isdecimal() else (1, part) for part in prerelease.split(".")) - - return (False, nums, prerelease_key) - - -def _extract_link_defs(text: str) -> tuple[str, dict[str, str]]: - """Separate trailing reference-style link definitions from changelog text. - - git-cliff appends reference-style link definitions at the bottom of - CHANGELOG.md for every version heading. When the changelog is split - into per-version blocks these definitions must be distributed to the - correct output files so that headings like ``## [0.7.2]`` resolve and - no unused definitions trigger rumdl MD053. - - Parameters: - text: The full changelog text. - - Returns: - A 2-tuple of (*cleaned_text*, *link_defs*) where *link_defs* maps - lowercase labels to their full definition lines. - """ - lines = text.rstrip("\n").split("\n") - link_defs: dict[str, str] = {} - - # Walk backwards from the end, collecting link-def and blank lines. - i = len(lines) - 1 - while i >= 0: - line = lines[i] - m = _LINK_DEF_RE.match(line) - if m: - link_defs[m.group(1).lower()] = line - i -= 1 - elif line.strip() == "": - i -= 1 - else: - break - - cleaned = "\n".join(lines[: i + 1]) - return cleaned.rstrip("\n") + "\n", link_defs - - -def parse_changelog(text: str) -> tuple[str, str, list[tuple[str, str]]]: - """Split a full changelog into preamble, unreleased block, and version blocks. - - Parameters: - text: The full contents of CHANGELOG.md. - - Returns: - A 3-tuple of (preamble, unreleased_block, version_blocks). The - ``unreleased_block`` is the complete ``## [Unreleased]`` block, - including its heading. Each item in ``version_blocks`` is a - ``(semver_label, full_heading_block)`` pair in the order it appears - (newest first), where ``semver_label`` is only the parsed version text - (for example ``"0.7.2"``) and ``full_heading_block`` still includes - the raw ``## [...]`` heading and body. - """ - lines = text.split("\n") - - # Locate all ``## [`` headings. - headings: list[int] = [] - for i, line in enumerate(lines): - if _VERSION_HEADING_RE.match(line): - headings.append(i) - - if not headings: - return text, "", [] - - preamble = "\n".join(lines[: headings[0]]) - - unreleased = "" - unreleased_line: int | None = None - version_blocks: list[tuple[str, str]] = [] - version_lines: dict[str, int] = {} - - for idx, start in enumerate(headings): - end = headings[idx + 1] if idx + 1 < len(headings) else len(lines) - block = "\n".join(lines[start:end]) - - heading_line = lines[start] - if _UNRELEASED_HEADING_RE.match(heading_line): - if unreleased_line is not None: - msg = f"Duplicate Unreleased changelog heading at lines {unreleased_line} and {start + 1}" - raise ValueError(msg) - unreleased = block - unreleased_line = start + 1 - else: - m = _VERSION_RE.match(heading_line) - if not m: - msg = f"Unrecognized changelog version heading at line {start + 1}: {heading_line!r}; expected '## [Unreleased]' or a semantic version" - raise ValueError(msg) - version = m.group(1) - if version in version_lines: - msg = f"Duplicate changelog version {version!r} at lines {version_lines[version]} and {start + 1}" - raise ValueError(msg) - version_lines[version] = start + 1 - version_blocks.append((version, block)) - - return preamble, unreleased, version_blocks - - -def group_by_minor( - version_blocks: list[tuple[str, str]], -) -> dict[str, list[tuple[str, str]]]: - """Group version blocks by their ``X.Y`` minor key. - - Preserves insertion order (newest first within each minor). - - Parameters: - version_blocks: List of ``(version, block_text)`` pairs. - - Returns: - An ordered dict mapping minor keys to their version blocks. - """ - groups: dict[str, list[tuple[str, str]]] = {} - for ver, block in version_blocks: - key = _minor_key(ver) - groups.setdefault(key, []).append((ver, block)) - return groups - - -def _validate_release_order(version_blocks: list[tuple[str, str]]) -> None: - """Require release headings to be in strictly descending SemVer order.""" - for (previous, _), (current, _) in pairwise(version_blocks): - same_precedence = _version_sort_key(previous) == _version_sort_key(current) - out_of_order = sorted((previous, current), key=_version_sort_key, reverse=True) != [previous, current] - if same_precedence or out_of_order: - msg = f"changelog release headings must be in strictly descending semantic-version order: {previous} appears before {current}" - raise ValueError(msg) - - -# --------------------------------------------------------------------------- -# Writers -# --------------------------------------------------------------------------- - - -def _format_link_defs(link_defs: dict[str, str], labels: set[str]) -> str: - """Return the subset of *link_defs* whose labels are in *labels*. - - The definitions are returned in reverse-sorted order (matching the - convention that git-cliff uses: ``[unreleased]`` first, then newest - version to oldest). - """ - relevant = [link_defs[label] for label in sorted(link_defs, key=_version_sort_key, reverse=True) if label in labels] - return "\n".join(relevant) if relevant else "" - - -def write_archive( - archive_dir: Path, - minor: str, - blocks: list[tuple[str, str]], - link_defs: dict[str, str] | None = None, -) -> Path: - """Write an archive file for a single minor series. - - Parameters: - archive_dir: Directory for archive files. - minor: The ``X.Y`` minor key. - blocks: Version blocks belonging to this minor, newest first, using - the ``(semver_label, full_heading_block)`` shape returned by - ``parse_changelog``. The archive writer preserves each provided - block verbatim after the generated archive title. - link_defs: Optional mapping of lowercase labels to reference-style - link definition lines. Only definitions matching versions in - *blocks* are included. - - Returns: - The path of the written archive file. - """ - path = archive_dir / f"{minor}.md" - text = _build_archive_text(minor, blocks, link_defs) - _publish_texts({path: text}) - return path - - -def _build_archive_text( - minor: str, - blocks: list[tuple[str, str]], - link_defs: dict[str, str] | None = None, -) -> str: - """Build one normalized archive payload without changing the filesystem.""" - parts = [f"# Changelog - {minor}.x\n"] - for _ver, block in blocks: - parts.append(block) - - text = "\n".join(parts) - - # Append only the reference-style link definitions for this archive. - if link_defs: - versions = {ver.lower() for ver, _ in blocks} - defs_text = _format_link_defs(link_defs, versions) - if defs_text: - text = text.rstrip("\n") + "\n\n" + defs_text - - # Normalize archive output too; archived blocks can preserve historical - # commit-body indentation that no longer appears in the trimmed root file. - return postprocess_text(text) - - -def _stage_text(path: Path, text: str) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - staged: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - staged = Path(handle.name) - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - except BaseException: - if staged is not None: - staged.unlink(missing_ok=True) - raise - if staged is None: - msg = "temporary changelog staging completed without a path" - raise AssertionError(msg) - return staged - - -def _stage_bytes(path: Path, payload: bytes) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - staged: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "wb", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - staged = Path(handle.name) - handle.write(payload) - handle.flush() - os.fsync(handle.fileno()) - except BaseException: - if staged is not None: - staged.unlink(missing_ok=True) - raise - if staged is None: - msg = "temporary changelog restoration completed without a path" - raise AssertionError(msg) - return staged - - -def _replace_path(source: Path, destination: Path) -> None: - source.replace(destination) - - -def _restore_text(path: Path, previous: bytes | None) -> None: - if previous is None: - path.unlink(missing_ok=True) - return - staged = _stage_bytes(path, previous) - try: - _replace_path(staged, path) - finally: - staged.unlink(missing_ok=True) - - -def _publish_texts(payloads: dict[Path, str]) -> None: - """Publish a set of text files together and roll all of them back on failure.""" - if not payloads: - return - previous = {path: path.read_bytes() if path.is_file() else None for path in payloads} - staged: dict[Path, Path] = {} - replaced: list[Path] = [] - try: - for path, text in payloads.items(): - staged[path] = _stage_text(path, text) - for path, staged_path in staged.items(): - _replace_path(staged_path, path) - replaced.append(path) - except BaseException as publication_error: - rollback_errors: list[BaseException] = [] - for path in reversed(replaced): - try: - _restore_text(path, previous[path]) - except BaseException as rollback_error: # noqa: BLE001 - rollback_errors.append(rollback_error) - if rollback_errors: - group_message = "changelog publication and rollback failed" - raise BaseExceptionGroup( - group_message, - [publication_error, *rollback_errors], - ) from None - raise - finally: - for staged_path in staged.values(): - staged_path.unlink(missing_ok=True) - - -def _postprocess_existing_archives(archive_dir: Path) -> None: - """Normalize historical archive files that are not regenerated this run.""" - if not archive_dir.is_dir(): - return - - payloads: dict[Path, str] = {} - for path in archive_dir.glob("*.md"): - text = path.read_text(encoding="utf-8") - normalized = normalize_entry_headings_text(text) - if normalized != text: - payloads[path] = normalized - _publish_texts(payloads) - - -def _normalized_existing_archive_payloads(archive_dir: Path, *, excluded: set[Path]) -> dict[Path, str]: - """Return changed historical archive payloads not regenerated this run.""" - if not archive_dir.is_dir(): - return {} - payloads: dict[Path, str] = {} - for path in archive_dir.glob("*.md"): - if path in excluded: - continue - text = path.read_text(encoding="utf-8") - normalized = normalize_entry_headings_text(text) - if normalized != text: - payloads[path] = normalized - return payloads - - -def build_root( - preamble: str, - unreleased: str, - active_blocks: list[tuple[str, str]], - archived_minors: list[str], - archive_dir_rel: str, -) -> str: - """Assemble the trimmed root CHANGELOG.md content. - - Parameters: - preamble: Text before the first ``## `` heading. - unreleased: The full Unreleased block (empty string if absent). - active_blocks: Version blocks for the active minor series. - archived_minors: Sorted list of archived ``X.Y`` minor keys. - archive_dir_rel: Relative path to the archive directory from the changelog file. - - Returns: - The full text for the trimmed CHANGELOG.md. - """ - parts: list[str] = [preamble] - - if unreleased: - parts.append(unreleased) - - for _ver, block in active_blocks: - parts.append(block) - - if archived_minors: - # Build the Archives section. - archive_lines = ["## Archives\n"] - archive_lines.append("Older releases are archived by minor series:\n") - archive_lines.extend(f"- [{minor}.x]({archive_dir_rel}/{minor}.md)" for minor in archived_minors) - archive_lines.append("") - parts.append("\n".join(archive_lines)) - - return postprocess_text("\n".join(parts)) - - -def _archive_dir_link_prefix(archive_dir: Path, changelog_parent: Path) -> str: - """Return the Markdown link prefix from a changelog to its archive directory.""" - try: - return archive_dir.relative_to(changelog_parent).as_posix() - except ValueError: - try: - archive_dir_rel = Path(os.path.relpath(archive_dir, changelog_parent)).as_posix() - except ValueError as err: - msg = "cannot compute relative archive links because the archive and changelog directories are on different filesystem roots" - raise ValueError(msg) from err - if archive_dir_rel == ".." or archive_dir_rel.startswith("../") or Path(archive_dir_rel).is_absolute(): - LOGGER.warning( - "Archive directory %s is outside changelog directory %s; generated Markdown links use %s", - archive_dir, - changelog_parent, - archive_dir_rel, - ) - return archive_dir_rel - - -# --------------------------------------------------------------------------- -# Orchestrator -# --------------------------------------------------------------------------- - - -def archive_changelog( - changelog_path: Path, - archive_dir: Path | None = None, -) -> None: - """Split a changelog into root + per-minor archive files. - - Parameters: - changelog_path: Path to the full CHANGELOG.md. - archive_dir: Directory for archive files. Defaults to - ``docs/archive/changelog`` relative to *changelog_path*'s parent. - """ - if archive_dir is None: - archive_dir = changelog_path.parent / _DEFAULT_ARCHIVE_DIR - - text = changelog_path.read_text(encoding="utf-8") - - # Separate trailing reference-style link definitions before parsing - # so they can be distributed to the correct output files. - text, link_defs = _extract_link_defs(text) - - preamble, unreleased, version_blocks = parse_changelog(text) - _validate_release_order(version_blocks) - - if not version_blocks: - _postprocess_existing_archives(archive_dir) - return # nothing to archive - - groups = group_by_minor(version_blocks) - minor_keys = list(groups.keys()) - - # Active minor = first minor that appears (newest release). - active_minor = minor_keys[0] - - # Archive every minor except the active one. - archived_minors = minor_keys[1:] - - if not archived_minors: - _postprocess_existing_archives(archive_dir) - return # only one minor series โ€” nothing to archive yet - - # Validate link portability before creating or modifying archive files. - # In particular, os.path.relpath() cannot cross Windows volumes. - archive_dir_rel = _archive_dir_link_prefix(archive_dir, changelog_path.parent) - - payloads = {archive_dir / f"{minor}.md": _build_archive_text(minor, groups[minor], link_defs) for minor in archived_minors} - - root_text = build_root( - preamble, - unreleased, - groups[active_minor], - sorted(archived_minors, key=_version_sort_key, reverse=True), - archive_dir_rel, - ) - - # Append reference-style link definitions for active versions. - if link_defs: - labels: set[str] = {ver.lower() for ver, _ in groups[active_minor]} - if unreleased: - labels.add("unreleased") - defs_text = _format_link_defs(link_defs, labels) - if defs_text: - root_text = root_text.rstrip("\n") + "\n\n" + defs_text + "\n" - - payloads[changelog_path] = root_text - payloads.update(_normalized_existing_archive_payloads(archive_dir, excluded=set(payloads))) - _publish_texts(payloads) - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def main(argv: list[str] | None = None) -> int: - """CLI entry point for ``archive-changelog``.""" - parser = argparse.ArgumentParser( - prog="archive-changelog", - description="Archive completed minor series from CHANGELOG.md.", - ) - parser.add_argument( - "path", - nargs="?", - default="CHANGELOG.md", - help="Path to CHANGELOG.md (default: CHANGELOG.md)", - ) - parser.add_argument( - "--archive-dir", - default=None, - help=f"Archive output directory (default: {_DEFAULT_ARCHIVE_DIR})", - ) - args = parser.parse_args(argv) - - changelog = Path(args.path) - try: - if not changelog.is_file(): - msg = f"{changelog} not found" - raise FileNotFoundError(msg) - archive_dir = Path(args.archive_dir) if args.archive_dir else None - archive_changelog(changelog, archive_dir) - except (OSError, UnicodeError, ValueError) as error: - print(f"archive-changelog: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check_docs_version_sync.py b/scripts/check_docs_version_sync.py index 8aca452..412c176 100644 --- a/scripts/check_docs_version_sync.py +++ b/scripts/check_docs_version_sync.py @@ -20,6 +20,7 @@ ".tmp_pycache", ".venv", "archive", + "archives", "target", "tests", } diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py deleted file mode 100644 index c7e6952..0000000 --- a/scripts/postprocess_changelog.py +++ /dev/null @@ -1,1001 +0,0 @@ -#!/usr/bin/env -S uv run -"""Post-process a git-cliff generated CHANGELOG.md. - -Applies lightweight markdown hygiene that is difficult to express in -Tera templates: - - 1. Inject summary sections (Breaking Changes, Merged Pull Requests). - 2. Reflow long lines at word boundaries, preserving markdown links - and code spans as atomic tokens (MD013). - 3. Tag bare fenced code blocks with a language (MD040). - 4. Normalize indented commit-body headings (MD023). - 5. Normalize list continuation indentation (MD077). - 6. Normalize escaped email autolinks (MD034). - 7. Strip trailing blank lines (MD012). - -Usage: - postprocess-changelog # default: CHANGELOG.md - postprocess-changelog path/to/CHANGELOG.md -""" - -import argparse -import os -import re -import stat -import sys -import tempfile -from dataclasses import dataclass -from pathlib import Path -from typing import cast - -# rumdl MD013 line-length limit used by this project. -MAX_LINE_WIDTH = 160 - - -@dataclass(frozen=True, slots=True) -class _CodeFence: - """Delimiter evidence for an open Markdown fenced code block.""" - - delimiter: str - length: int - - -_FENCE_RE = re.compile(r"^(?P[ \t]*)(?P`{3,}|~{3,})(?P.*)$") - - -def _backtick_span_end(text: str, start: int) -> int | None: - """Return the end of a code span whose closing run matches its opener.""" - delimiter_length = 1 - while start + delimiter_length < len(text) and text[start + delimiter_length] == "`": - delimiter_length += 1 - - position = start + delimiter_length - while position < len(text): - run_start = text.find("`", position) - if run_start < 0: - return None - run_end = run_start + 1 - while run_end < len(text) and text[run_end] == "`": - run_end += 1 - if run_end - run_start == delimiter_length: - return run_end - position = run_end - return None - - -def _balanced_delimiter_end(text: str, start: int, opening: str, closing: str) -> int | None: - """Return the end of a balanced delimiter pair, honoring escapes.""" - depth = 1 - position = start + 1 - while position < len(text): - character = text[position] - if character == "\\" and position + 1 < len(text): - position += 2 - continue - if character == opening: - depth += 1 - elif character == closing: - depth -= 1 - if depth == 0: - return position + 1 - position += 1 - return None - - -def _markdown_link_end(text: str, start: int) -> int | None: - """Return the end of an inline link, balancing brackets and parentheses.""" - label_end = _balanced_delimiter_end(text, start, "[", "]") - if label_end is None or label_end >= len(text) or text[label_end] != "(": - return None - return _balanced_delimiter_end(text, label_end, "(", ")") - - -def _markdown_tokens(text: str) -> list[str]: - """Tokenize reflowable prose without splitting links or code spans.""" - tokens: list[str] = [] - position = 0 - while position < len(text): - while position < len(text) and text[position].isspace(): - position += 1 - if position >= len(text): - break - - token_end: int | None = None - if text[position] == "[": - token_end = _markdown_link_end(text, position) - elif text[position] == "`": - token_end = _backtick_span_end(text, position) - - if token_end is None: - token_end = position + 1 - while token_end < len(text) and not text[token_end].isspace(): - token_end += 1 - tokens.append(text[position:token_end]) - position = token_end - return tokens - - -# Version section heading: ## [X.Y.Z], ## [vX.Y.Z], or ## [Unreleased] -_VERSION_RE = re.compile( - r"^## \[(?:v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?|Unreleased)\]" - r"(?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$" -) - -# PR link: [#123](https://github.com/.../pull/123) -_PR_LINK_RE = re.compile(r"\[#(\d+)\]\(https://github\.com/[^)]+/pull/\d+\)") - -# Commit-hash link to strip from summary lines. -_COMMIT_LINK_RE = re.compile(r"\s*\[`[a-f0-9]{7}`\]\(https://github\.com/[^)]+/commit/[a-f0-9]+\)") - -# Leading git-cliff breaking marker to strip from normalized comparison keys. -_BREAKING_MARKER_RE = re.compile(r"^\s*(?:[-*]\s+)?\[?\*\*breaking\*\*\]?\s*", re.IGNORECASE) - -# Leading ``* `` list marker to normalise to ``- `` (MD004). -_STAR_LIST_RE = re.compile(r"^(\s*)\* ") - -# Extra spaces after list marker: ``- `` โ†’ ``- `` (MD030). -_LIST_MARKER_SPACE_RE = re.compile(r"^(\s*-)\s{2,}") - -# Indented ATX headings from commit bodies: `` ## Title`` โ†’ `` **Title**``. -_INDENTED_ATX_HEADING_RE = re.compile(r"^(?P\s+)#{1,6}\s+(?P.*?)(?:\s+#+\s*)?$") - -# Squash-merge commit bodies often contain inner conventional-commit -# headings from the PR branch: ``* fix: thing``. After MD004 normalization -# those become ordinary list items, which makes them look like separate -# generated commits. Treat them as prose headings inside the parent entry. -_SQUASH_HEADING_RE = re.compile(r"^(?P<indent>\s*)-\s+(?P<prefix>[A-Za-z]+(?:\([^)]+\))?!?):\s+(?P<title>.+?)\s*$") - -# Changelog section headings that may appear at column zero under a version -# heading. Other accidental ``##``/``###`` headings from commit bodies are -# demoted to entry-level headings so they cannot split the generated hierarchy. -_CHANGELOG_SECTION_HEADINGS = { - "Added", - "Changed", - "Deprecated", - "Dependencies", - "Documentation", - "Fixed", - "Maintenance", - "Merged Pull Requests", - "Performance", - "Removed", - "Security", - "โš ๏ธ Breaking Changes", -} -_ENTRY_HEADING_RE = re.compile(r"^(?P<level>#{2,6})\s+(?P<title>.*?)(?:\s+#+\s*)?$") - -# git-cliff HTML-escapes co-author email angle brackets. Markdown wants real -# angle brackets for email autolinks, otherwise rumdl treats the address as bare. -_ESCAPED_EMAIL_RE = re.compile(r"<(?P<email>[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})>", re.IGNORECASE) - -# This label set is intentionally broad, including release labels such as -# "added", "fixed", "changed", "removed", and "deprecated". Rewriting is -# only allowed when _is_isolated_body_heading accepts the line; do not relax -# that guard because tests rely on it to preserve handcrafted sub-bullets. -_SQUASH_HEADING_LABELS: dict[str, str] = { - "feat": "Added", - "fix": "Fixed", - "perf": "Performance", - "refactor": "Changed", - "test": "Changed", - "style": "Changed", - "build": "Maintenance", - "chore": "Maintenance", - "ci": "Maintenance", - "doc": "Documentation", - "docs": "Documentation", - "added": "Added", - "fixed": "Fixed", - "changed": "Changed", - "performance": "Performance", - "documentation": "Documentation", - "maintenance": "Maintenance", - "deprecated": "Deprecated", - "removed": "Removed", -} - - -def _is_normalized_squash_heading(line: str) -> bool: - """Return whether *line* is a bold heading emitted for a squash body.""" - stripped = line.strip() - return stripped.endswith("**") and any(stripped.startswith(f"**{label}: ") for label in _SQUASH_HEADING_LABELS.values()) - - -def _plain_summary(text: str) -> str: - """Return a normalized comparison key for changelog entry text.""" - text = _BREAKING_MARKER_RE.sub("", text) - text = _COMMIT_LINK_RE.sub("", text) - text = _PR_LINK_RE.sub("", text) - text = re.sub(r"^\s*[-*]\s+", "", text) - text = re.sub(r"^[A-Za-z]+(?:\([^)]+\))?!?:\s+", "", text) - return re.sub(r"\s+", " ", text).strip().casefold() - - -def _squash_heading_parts(line: str) -> tuple[str, str, str] | None: - """Return ``(indent, label, title)`` for a squash-body pseudo-heading.""" - if _COMMIT_LINK_RE.search(line): - return None - - match = _SQUASH_HEADING_RE.match(line) - if match is None: - return None - - raw_prefix = cast("str", match.group("prefix")) - kind = re.sub(r"\([^)]+\)", "", raw_prefix).rstrip("!").casefold() - label = _SQUASH_HEADING_LABELS.get(kind) - if label is None: - return None - - title = cast("str", match.group("title")).strip() - if not title: - return None - - return cast("str", match.group("indent")), label, title[0].upper() + title[1:] - - -def _normalize_squash_heading(line: str, *, nested: bool = False) -> str: - """ - Convert squash-merge pseudo-commit bullets into bold prose headings. - - This keeps release-note subsections from PR squash bodies readable while - avoiding fake top-level changelog entries. - """ - parts = _squash_heading_parts(line) - if parts is None: - return line - - indent, label, title = parts - if nested and not indent: - indent = " " - return f"{indent}**{label}: {title}**" - - -def _is_duplicate_squash_heading(line: str, parent_summary: str | None) -> bool: - """Return true when a squash-body heading repeats its parent entry.""" - parts = _squash_heading_parts(line) - if parts is None or parent_summary is None: - return False - - _, _, title = parts - return _plain_summary(title) == parent_summary - - -def _is_isolated_body_heading(lines: list[str], idx: int) -> bool: - """Return true when a body line is separated like a squash heading.""" - prev_is_blank = idx > 0 and not lines[idx - 1].strip() - next_is_blank = idx + 1 < len(lines) and not lines[idx + 1].strip() - return prev_is_blank and next_is_blank - - -def _is_squash_heading_candidate(lines: list[str], idx: int) -> bool: - """Return true when an original body line will become bold prose.""" - return _squash_heading_parts(lines[idx]) is not None and _is_isolated_body_heading(lines, idx) - - -def _max_pr_number(entry: str) -> int: - """ - Get the largest pull request number referenced in the given changelog entry. - - Returns: - highest_pr (int): The largest PR number found, or 0 if no PR links are present. - """ - numbers = [int(m) for m in _PR_LINK_RE.findall(entry)] - return max(numbers) if numbers else 0 - - -def _compact_entry(line: str, *, strip_breaking: bool = False) -> str: - """ - Produce a compact summary of a changelog list item. - - Removes a trailing commit-hash link from the given line. If `strip_breaking` is True, - also removes a single leading breaking marker. - - Parameters: - line (str): The changelog list item to compact. - strip_breaking (bool): If True, strip a single leading breaking marker. - - Returns: - str: The compacted changelog entry with the commit-hash link (and optional breaking prefix) removed. - """ - result = _COMMIT_LINK_RE.sub("", line).rstrip() - if strip_breaking: - bullet = result[:2] if result.startswith(("- ", "* ")) else "" - body = result[2:] if bullet else result - result = bullet + _BREAKING_MARKER_RE.sub("", body, count=1) - return result - - -def _append_unique(entries: list[str], entry: str) -> None: - """Append *entry* to *entries* only once, preserving first-seen order.""" - if entry not in entries: - entries.append(entry) - - -def _extract_section_summaries( - section: list[str], -) -> tuple[list[str], list[str]]: - """ - Extract summary lines for merged pull requests and breaking changes from a version section. - - Processes only top-level list items in the provided `section` (lines starting with "- " or - "* "), detects PR-linked entries and entries containing a breaking marker. Each matching line - is compacted (trailing commit-hash links removed; the breaking marker is stripped when - requested) before inclusion. - - Parameters: - section (list[str]): Lines belonging to a single version section from a changelog. - - Returns: - tuple[list[str], list[str]]: `pr_entries` โ€” compacted lines that contain PR links; - `breaking_entries` โ€” compacted lines marked as breaking changes. - """ - pr_entries: list[str] = [] - breaking_entries: list[str] = [] - - for sline in section: - # Only top-level list items (no leading whitespace). - if not sline.startswith(("- ", "* ")): - continue - - is_breaking = bool(_BREAKING_MARKER_RE.search(sline)) - has_pr = bool(_PR_LINK_RE.search(sline)) - - if is_breaking: - _append_unique(breaking_entries, _compact_entry(sline, strip_breaking=True)) - if has_pr: - _append_unique(pr_entries, _compact_entry(sline, strip_breaking=True)) - - return pr_entries, breaking_entries - - -def _summary_insertion_index(section: list[str]) -> int: - """Place new summaries after the version heading or an existing breaking summary.""" - if "### โš ๏ธ Breaking Changes" in section: - index = section.index("### โš ๏ธ Breaking Changes") + 1 - while index < len(section) and not section[index].startswith("### "): - index += 1 - else: - index = 1 - while index < len(section) and not section[index].strip(): - index += 1 - return index - - -def _inject_summary_sections(text: str) -> str: - """ - Insert "Merged Pull Requests" and "Breaking Changes" summary sections into a changelog text. - - Scans each version section for PR-linked list items and entries marked as breaking, - builds compact summary lists (sorted by PR number), and injects a summary block - immediately after the version heading when relevant. - - Returns: - processed_text (str): The input text with summary sections inserted; unchanged if - no version sections or no summary entries are found. - """ - lines = text.split("\n") - - # Locate version-section boundaries. - boundaries: list[int] = [] - for i, line in enumerate(lines): - if _VERSION_RE.match(line): - boundaries.append(i) - - if not boundaries: - return text - - # Walk sections in reverse so insertions don't shift later indices. - for sec_idx in reversed(range(len(boundaries))): - start = boundaries[sec_idx] - end = boundaries[sec_idx + 1] if sec_idx + 1 < len(boundaries) else len(lines) - section = lines[start:end] - - # git-cliff renders full breaking descriptions; older input may have neither summary. - has_pr_summary = "### Merged Pull Requests" in section - has_breaking_summary = "### โš ๏ธ Breaking Changes" in section - pr_entries, breaking_entries = _extract_section_summaries(section) - if has_pr_summary: - pr_entries = [] - if has_breaking_summary: - breaking_entries = [] - - if not pr_entries and not breaking_entries: - continue - - # Sort PRs by highest PR number, descending (newest first). - pr_entries.sort(key=_max_pr_number, reverse=True) - - insert_at = start + _summary_insertion_index(section) - - block: list[str] = [] - if breaking_entries: - block.append("### โš ๏ธ Breaking Changes") - block.append("") - block.extend(breaking_entries) - block.append("") - if pr_entries: - block.append("### Merged Pull Requests") - block.append("") - block.extend(pr_entries) - block.append("") - - lines[insert_at:insert_at] = block - - return "\n".join(lines) - - -def _reflow_line(line: str, max_width: int = MAX_LINE_WIDTH) -> str: - """ - Reflow a single markdown line to fit within max_width while preserving atomic markdown tokens. - - Preserves a leading list marker ("- " or "* ") on the first line and indents continuation - lines to maintain list nesting. Tokens such as links and code spans are kept intact and not - split across lines. - - Parameters: - line (str): The original line to reflow. - max_width (int): Maximum allowed line width; lines longer than this will be wrapped. - - Returns: - str: The reflowed line, potentially containing newline characters so that no output line exceeds max_width. - """ - if len(line) <= max_width: - return line - - stripped = line.lstrip() - indent = line[: len(line) - len(stripped)] - - # Determine first-line prefix vs continuation indent. - if stripped.startswith(("- ", "* ")): - first_prefix = indent + stripped[:2] - content = stripped[2:] - cont_indent = indent + " " - else: - first_prefix = indent - content = stripped - cont_indent = indent - - tokens = _markdown_tokens(content) - if not tokens: - return line - - lines: list[str] = [] - current = first_prefix + tokens[0] - - for token in tokens[1:]: - candidate = current + " " + token - if len(candidate) <= max_width: - current = candidate - else: - lines.append(current) - current = cont_indent + token - - lines.append(current) - return "\n".join(lines) - - -def _deindent_orphan(line: str, lines: list[str], idx: int) -> str: - """ - Normalize indentation for sub-bullet list items produced by git-cliff. - - Cliff's ``indent(prefix=" ")`` filter can compound with pre-existing - indentation in commit bodies, producing non-standard nesting depths. - This function scans backward through the original *lines* to find the - nearest list ancestor and normalizes the indent to ``parent + 2`` - spaces (MD007). - """ - stripped = line.lstrip() - if not (line.startswith(" ") and stripped.startswith("- ")): - return line - - our_indent = len(line) - len(stripped) - nearest_parent_indent: int | None = None - - for j in range(idx - 1, -1, -1): - prev = lines[j] - if not prev.strip(): - continue # skip blanks - if prev.startswith(" "): - prev_stripped = prev.lstrip() - if prev_stripped.startswith(("- ", "* ")): - if _is_squash_heading_candidate(lines, j): - continue - parent_indent = len(prev) - len(prev_stripped) - if our_indent > parent_indent and nearest_parent_indent is None: - nearest_parent_indent = parent_indent - continue # skip cliff-indented content - # A normalized squash heading is a prose parent for its body bullets. - # Preserve that relationship on subsequent post-processing runs. - if _is_normalized_squash_heading(prev): - return " " + stripped - # Column-0 non-blank line โ€” determines final result. - is_list_parent = prev.startswith(("- ", "* ")) - if is_list_parent: - base = nearest_parent_indent + 2 if nearest_parent_indent is not None else 2 - return " " * base + stripped - # Column-0 non-list โ€” orphan. - return line[2:] if nearest_parent_indent is not None else stripped - # Reached top of document โ€” orphan. - return line[2:] if nearest_parent_indent is not None else stripped - - -def _normalize_list_continuation_indent(line: str, lines: list[str], idx: int) -> str: - """ - Normalize generated list continuation indentation. - - git-cliff commit bodies can contain pre-indented prose under a list item. - Markdown treats that prose as list-continuation content, where rumdl's MD077 - expects exactly two spaces past the parent bullet indentation. - """ - stripped = line.lstrip() - if not line.startswith(" ") or not stripped or stripped.startswith(("- ", "* ")): - return line - - our_indent = len(line) - len(stripped) - - for j in range(idx - 1, -1, -1): - prev = lines[j] - if not prev.strip(): - continue - - prev_stripped = prev.lstrip() - if prev_stripped.startswith(("- ", "* ")): - parent_indent = len(prev) - len(prev_stripped) - expected_indent = parent_indent + 2 - if our_indent > expected_indent: - return " " * expected_indent + stripped - return line - - if not prev.startswith(" "): - return line - - return line - - -def _list_item_indent(line: str) -> int | None: - """Return the indentation of a Markdown list item, if *line* is one.""" - stripped = line.lstrip() - if not stripped.startswith(("- ", "* ")): - return None - return len(line) - len(stripped) - - -def _has_previous_peer_list_item(lines: list[str], idx: int, peer_indent: int) -> bool: - """Return true if a prior list item exists at *peer_indent* before *idx*.""" - for j in range(idx - 1, -1, -1): - prev = lines[j] - if not prev.strip(): - continue - - prev_indent = _list_item_indent(prev) - if prev_indent == peer_indent: - return True - if prev_indent is not None or prev.startswith(" "): - continue - return False - - return False - - -def _next_list_item_indent(lines: list[str], idx: int) -> int | None: - """Return the next nonblank line's list-item indentation, if it is a list item.""" - for next_line in lines[idx + 1 :]: - if not next_line.strip(): - continue - return _list_item_indent(next_line) - return None - - -def _is_blank_between_peer_list_items(lines: list[str], idx: int) -> bool: - """Return true when a blank line separates adjacent items in the same list.""" - if lines[idx].strip(): - return False - - next_indent = _next_list_item_indent(lines, idx) - if next_indent is None: - return False - - return _has_previous_peer_list_item(lines, idx, next_indent) - - -def _normalize_email_autolinks(line: str) -> str: - """Convert git-cliff's escaped email autolinks into Markdown autolinks.""" - return _ESCAPED_EMAIL_RE.sub(r"<\g<email>>", line) - - -def _needs_blank_before(line: str, lines: list[str], idx: int, result: list[str]) -> bool: - """ - Determine whether a blank line is required before a list item to satisfy Markdown rule MD032. - - Parameters: - line (str): The current line. - lines (list[str]): The source lines being post-processed. - idx (int): The index of the current line in ``lines``. - result (list[str]): The lines already emitted immediately before the current line. - - Returns: - bool: `True` if a blank line should be inserted before the list item, `False` otherwise. - """ - stripped = line.lstrip() - if not stripped.startswith("- ") or not result or not result[-1].strip(): - return False - - prev = result[-1].lstrip() - if prev.startswith("#"): - return False - if prev.startswith("- "): - return False - - current_indent = len(line) - len(stripped) - return not _has_previous_peer_list_item(result, len(result), current_indent) - - -def _normalize_indented_heading(line: str) -> str: - """ - Convert indented commit-body headings into bold prose. - - git-cliff indents commit bodies under each changelog entry. If a historical - commit body contains an ATX heading such as ``## Correctness Fixes``, the - rendered changelog contains `` ## Correctness Fixes``. rumdl still treats - that as a heading, but MD023 requires headings to start at column 0. - Keeping the text as bold prose preserves readability without changing the - generated changelog hierarchy. - """ - match = _INDENTED_ATX_HEADING_RE.match(line) - if match is None: - return line - - title = match.group("title").strip() - if not title: - return line - - return f"{match.group('indent')}**{title}**" - - -def _normalize_entry_heading(line: str) -> str: - """Demote accidental column-zero commit-body headings to entry headings.""" - match = _ENTRY_HEADING_RE.match(line) - if match is None: - return line - - level = match.group("level") - title = match.group("title").strip() - if not title or level.startswith("####"): - return line - if level == "##" and (_VERSION_RE.match(line) or title == "Archives"): - return line - if level == "###" and title in _CHANGELOG_SECTION_HEADINGS: - return line - return f"#### {title}" - - -def normalize_entry_headings_text(text: str) -> str: - """Normalize accidental entry headings in an existing changelog document.""" - result: list[str] = [] - active_fence: _CodeFence | None = None - - for line in text.split("\n"): - if active_fence is not None: - result.append(line) - if _closes_code_fence(line, active_fence): - active_fence = None - continue - - active_fence = _opening_code_fence(line) - if active_fence is not None: - result.append(line) - continue - result.append(_normalize_entry_heading(line)) - - return "\n".join(result).rstrip("\n") + "\n" - - -def _fence_parts(line: str) -> tuple[str, str, str] | None: - """Return indentation, delimiter run, and info string for a fence line.""" - match = _FENCE_RE.fullmatch(line) - if match is None: - return None - return ( - cast("str", match.group("indent")), - cast("str", match.group("fence")), - cast("str", match.group("info")), - ) - - -def _opening_code_fence(line: str) -> _CodeFence | None: - """Parse an opening backtick or tilde fence.""" - parts = _fence_parts(line) - if parts is None: - return None - _, delimiter_run, info = parts - delimiter = delimiter_run[0] - if delimiter == "`" and "`" in info: - return None - return _CodeFence(delimiter=delimiter, length=len(delimiter_run)) - - -def _closes_code_fence(line: str, active_fence: _CodeFence) -> bool: - """Return whether *line* validly closes *active_fence*.""" - parts = _fence_parts(line) - if parts is None: - return False - _, delimiter_run, info = parts - return delimiter_run[0] == active_fence.delimiter and len(delimiter_run) >= active_fence.length and not info.strip() - - -def _process_code_fence( - line: str, - result: list[str], - active_fence: _CodeFence | None, - next_line: str | None, -) -> tuple[bool, _CodeFence | None]: - """Handle fenced-code transitions and append the line when consumed.""" - if active_fence is None: - active_fence = _opening_code_fence(line) - if active_fence is None: - return False, None - # MD031: blank line before fenced code block. - if result and result[-1].strip(): - result.append("") - # MD040: add language tag if missing. - parts = _fence_parts(line) - if parts is not None: - indent, delimiter_run, info = parts - if not info.strip(): - line = f"{indent}{delimiter_run}text" - result.append(line) - return True, active_fence - - if not _closes_code_fence(line, active_fence): - return False, active_fence - - result.append(line) - if next_line is not None and next_line.strip(): - result.append("") - return True, None - - -def _update_entry_summary(line: str, current_entry_summary: str | None) -> str | None: - """Track the active changelog entry summary for squash-body cleanup.""" - if line.startswith("- ") and _COMMIT_LINK_RE.search(line): - return _plain_summary(line) - if line.startswith(("### ", "## ", "# ")): - return None - return current_entry_summary - - -def _should_skip_duplicate_heading( - line: str, - result: list[str], - current_entry_summary: str | None, - is_isolated_body_heading: bool, -) -> tuple[bool, bool]: - """Return whether to skip a duplicate squash heading and the following blank.""" - if is_isolated_body_heading and _is_duplicate_squash_heading(line, current_entry_summary): - return True, bool(result and not result[-1].strip()) - return False, False - - -def _normalize_body_line(line: str, lines: list[str], idx: int, result: list[str], current_entry_summary: str | None) -> str: - """Apply markdown hygiene transforms to a non-code line.""" - is_isolated_body_heading = _is_isolated_body_heading(lines, idx) - line = _deindent_orphan(line, lines, idx) - line = _normalize_list_continuation_indent(line, lines, idx) - line = _normalize_indented_heading(line) - line = _normalize_entry_heading(line) - - if is_isolated_body_heading: - line = _normalize_squash_heading(line, nested=current_entry_summary is not None) - - if _needs_blank_before(line, lines, idx, result): - result.append("") - - return _reflow_line(line) if len(line) > MAX_LINE_WIDTH else line - - -def _dependabot_metadata_end(lines: list[str], separator_index: int) -> int | None: - """Return the closing marker index for an unfenced Dependabot footer.""" - metadata_start = separator_index + 1 - while metadata_start < len(lines) and not lines[metadata_start].strip(): - metadata_start += 1 - if metadata_start >= len(lines) or lines[metadata_start].strip() != "updated-dependencies:": - return None - - metadata_end = metadata_start + 1 - while metadata_end < len(lines) and lines[metadata_end].strip() != "..." and _opening_code_fence(lines[metadata_end]) is None: - metadata_end += 1 - if metadata_end >= len(lines) or lines[metadata_end].strip() != "...": - return None - return metadata_end - - -def _strip_dependabot_metadata(text: str) -> str: - """Remove Dependabot's YAML metadata footer from rendered commit bodies.""" - lines = text.split("\n") - result: list[str] = [] - active_fence: _CodeFence | None = None - idx = 0 - - while idx < len(lines): - if active_fence is not None: - result.append(lines[idx]) - if _closes_code_fence(lines[idx], active_fence): - active_fence = None - idx += 1 - continue - - active_fence = _opening_code_fence(lines[idx]) - if active_fence is not None: - result.append(lines[idx]) - idx += 1 - continue - - metadata_end = _dependabot_metadata_end(lines, idx) if lines[idx].strip() == "---" else None - if metadata_end is None: - result.append(lines[idx]) - idx += 1 - continue - - while result and not result[-1].strip(): - result.pop() - idx = metadata_end + 1 - while idx < len(lines) and not lines[idx].strip(): - idx += 1 - if result and idx < len(lines): - result.append("") - - return "\n".join(result) - - -def _escape_breaking_prose(text: str) -> str: - """Escape HTML in breaking descriptions while preserving Markdown code.""" - result: list[str] = [] - in_breaking = False - active_fence: _CodeFence | None = None - for line in text.split("\n"): - if active_fence is not None: - result.append(line) - if _closes_code_fence(line, active_fence): - active_fence = None - continue - if line.startswith(("## ", "### ")): - in_breaking = line == "### โš ๏ธ Breaking Changes" - active_fence = _opening_code_fence(line) - if not in_breaking or active_fence is not None: - result.append(line) - continue - position = 0 - escaped: list[str] = [] - while position < len(line): - end = _backtick_span_end(line, position) if line[position] == "`" else None - if end is not None: - escaped.append(line[position:end]) - position = end - else: - escaped.append({"<": "<", ">": ">"}.get(line[position], line[position])) - position += 1 - result.append("".join(escaped)) - return "\n".join(result) - - -def postprocess_text(text: str) -> str: - """Apply changelog markdown hygiene transforms to *text*.""" - text = _strip_dependabot_metadata(text) - text = _escape_breaking_prose(text) - - # Inject PR / breaking-change summary sections before reflow. - text = _inject_summary_sections(text) - - lines = text.split("\n") - result: list[str] = [] - active_fence: _CodeFence | None = None - current_entry_summary: str | None = None - drop_next_blank = False - - for idx, raw_line in enumerate(lines): - line = raw_line - - # --- fenced code-block tracking --- - next_line = lines[idx + 1] if idx + 1 < len(lines) else None - handled, active_fence = _process_code_fence(line, result, active_fence, next_line) - if handled: - continue - - # Never reflow inside code blocks. - if active_fence is not None: - result.append(line) - continue - - if _is_blank_between_peer_list_items(lines, idx): - continue - - # --- MD004: normalise ``* `` list markers to ``- `` --- - line = _STAR_LIST_RE.sub(r"\1- ", line) - - # --- MD030: normalise spaces after list marker --- - line = _LIST_MARKER_SPACE_RE.sub(r"\1 ", line) - line = _normalize_email_autolinks(line) - - current_entry_summary = _update_entry_summary(line, current_entry_summary) - is_isolated_body_heading = _is_isolated_body_heading(lines, idx) - - # --- GitHub squash bodies: collapse duplicate pseudo-headings --- - should_skip, next_drop_blank = _should_skip_duplicate_heading( - line, - result, - current_entry_summary, - is_isolated_body_heading, - ) - if should_skip: - drop_next_blank = next_drop_blank - continue - if drop_next_blank and not line.strip(): - drop_next_blank = False - continue - drop_next_blank = False - - result.append(_normalize_body_line(line, lines, idx, result, current_entry_summary)) - - # 1. Reassemble and strip trailing blank lines. - text = "\n".join(result) - return text.rstrip("\n") + "\n" - - -def _write_text_atomic(path: Path, text: str) -> None: - """Replace an existing UTF-8 file atomically while preserving its mode.""" - mode = stat.S_IMODE(path.stat().st_mode) - staged: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - staged = Path(handle.name) - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - staged.chmod(mode) - staged.replace(path) - staged = None - finally: - if staged is not None: - staged.unlink(missing_ok=True) - - -def postprocess(path: Path) -> None: - """Read *path*, apply hygiene fixes, and write it back.""" - text = path.read_text(encoding="utf-8") - text = postprocess_text(text) - - _write_text_atomic(path, text) - - -def main() -> None: - """CLI entry point for ``postprocess-changelog``.""" - parser = argparse.ArgumentParser( - prog="postprocess-changelog", - description="Apply markdown hygiene to a git-cliff generated CHANGELOG.md.", - ) - parser.add_argument( - "path", - nargs="?", - default="CHANGELOG.md", - help="Path to CHANGELOG.md (default: CHANGELOG.md)", - ) - args = parser.parse_args() - - changelog = Path(args.path) - if not changelog.is_file(): - print(f"Error: {changelog} not found", file=sys.stderr) - sys.exit(1) - - postprocess(changelog) - - -if __name__ == "__main__": - main() diff --git a/scripts/tag_release.py b/scripts/tag_release.py deleted file mode 100755 index bc33164..0000000 --- a/scripts/tag_release.py +++ /dev/null @@ -1,436 +0,0 @@ -#!/usr/bin/env -S uv run -"""Create annotated git tags from CHANGELOG.md sections. - -Handles GitHub's 125KB tag-annotation size limit by falling back to a short -reference message when the changelog section is too large. - -Usage: - tag-release v1.2.3 # create annotated tag from CHANGELOG.md - tag-release v1.2.3 --force # recreate tag if it already exists - tag-release v1.2.3 --debug # verbose output - -Ported from the delaunay project's changelog_utils.py (tag-creation subset). -""" - -import argparse -import logging -import re -import subprocess -import sys -import tomllib -from pathlib import Path -from urllib.parse import urlsplit - -from subprocess_utils import ( - ExecutableNotFoundError, - format_exception_diagnostics, - run_git_command, - run_git_command_with_input, -) - -# GitHub's maximum size for git tag annotations (bytes) -_GITHUB_TAG_ANNOTATION_LIMIT = 125_000 - -# ANSI color codes for terminal output -_GREEN = "\033[0;32m" -_BLUE = "\033[0;34m" -_YELLOW = "\033[1;33m" -_RESET = "\033[0m" - -log = logging.getLogger(__name__) - -_GITHUB_SCP_REMOTE_RE = re.compile(r"^git@github\.com:(?P<path>.+)$", re.IGNORECASE) -_GITHUB_REPO_COMPONENT_RE = re.compile(r"^[A-Za-z0-9_.-]+$") - - -# --------------------------------------------------------------------------- -# SemVer validation -# --------------------------------------------------------------------------- - -# SemVer 2.0.0 strict with required 'v' prefix -# Alphanumeric prerelease identifier: any [0-9A-Za-z-]+ containing at least one -# non-digit. This permits identifiers like "1a" that start with a digit but are -# not purely numeric (SemVer 2.0.0 ยง9). -_ALNUM_ID = r"(?:(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z-]+)" -_SEMVER_RE = re.compile( - r"^v" - r"(0|[1-9]\d*)\." - r"(0|[1-9]\d*)\." - r"(0|[1-9]\d*)" - rf"(?:-(?:(?:0|[1-9]\d*)|{_ALNUM_ID})" - rf"(?:\.(?:(?:0|[1-9]\d*)|{_ALNUM_ID}))*" - r")?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" -) - - -def validate_semver(tag_version: str) -> None: - """Raise ``ValueError`` if *tag_version* is not valid ``vX.Y.Z`` SemVer.""" - if not _SEMVER_RE.match(tag_version): - msg = f"Tag version should follow SemVer format 'vX.Y.Z' (e.g., v0.3.5, v1.2.3-rc.1). Got: {tag_version}" - raise ValueError(msg) - - -def parse_version(tag_version: str) -> str: - """Return version string without leading ``v``.""" - return tag_version.removeprefix("v") - - -# --------------------------------------------------------------------------- -# Changelog helpers -# --------------------------------------------------------------------------- - - -def find_changelog(start: Path | None = None) -> Path: - """Locate ``CHANGELOG.md`` in *start* or its parent. - - Raises: - FileNotFoundError: If ``CHANGELOG.md`` cannot be found. - """ - base = start or Path.cwd() - for candidate in (base / "CHANGELOG.md", base.parent / "CHANGELOG.md"): - if candidate.is_file(): - return candidate - msg = "CHANGELOG.md not found in current directory or parent directory." - raise FileNotFoundError(msg) - - -def _package_version(changelog: Path) -> str: - """Return the authoritative Cargo package version beside the changelog.""" - cargo_toml = changelog.parent / "Cargo.toml" - try: - data = tomllib.loads(cargo_toml.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as exc: - msg = f"Could not parse {cargo_toml}: {exc}" - raise ValueError(msg) from exc - package = data.get("package") - if not isinstance(package, dict): - msg = f"{cargo_toml} does not define a [package] table" - raise TypeError(msg) - version = package.get("version") - if not isinstance(version, str) or not version.strip(): - msg = f"{cargo_toml} does not define a non-empty package version" - raise TypeError(msg) - return version - - -def _validated_release_target(tag_version: str) -> tuple[str, Path]: - """Validate a requested tag and return its version plus changelog path.""" - validate_semver(tag_version) - version = parse_version(tag_version) - changelog = find_changelog() - package_version = _package_version(changelog) - if version != package_version: - msg = f"Tag version {version!r} does not match Cargo package version {package_version!r}" - raise ValueError(msg) - return version, changelog - - -def _archive_path_for_version(changelog: Path, version: str) -> Path | None: - """Return the archive file path for *version* if it exists.""" - parts = version.split(".") - if len(parts) < 2: - return None - minor = f"{parts[0]}.{parts[1]}" - candidate = changelog.parent / "docs" / "archive" / "changelog" / f"{minor}.md" - return candidate if candidate.is_file() else None - - -def _extract_section_from_file(path: Path, version: str) -> str | None: - """Try to extract the changelog section for *version* from *path*.""" - header_re = _version_header_re(version) - content = path.read_text(encoding="utf-8") - - lines = content.split("\n") - section: list[str] = [] - collecting = False - - for line in lines: - if re.match(r"^##\s", line): - if collecting: - break - if header_re.match(line): - collecting = True - continue - elif collecting: - section.append(line) - - if not collecting: - return None - - start = 0 - while start < len(section) and not section[start].strip(): - start += 1 - end = len(section) - while end > start and not section[end - 1].strip(): - end -= 1 - - return "\n".join(section[start:end]) - - -def extract_changelog_section(changelog: Path, version: str) -> tuple[str, Path]: - """Extract the changelog body for *version* (without ``v`` prefix). - - Searches the root changelog first, then falls back to the per-minor - archive file under ``docs/archive/changelog``. - - Raises: - LookupError: If the version section is not found or empty. - """ - body = _extract_section_from_file(changelog, version) - if body is not None: - if not body.strip(): - msg = f"Changelog section for version {version} is empty." - raise LookupError(msg) - return body, changelog - - archive = _archive_path_for_version(changelog, version) - if archive: - body = _extract_section_from_file(archive, version) - if body is not None: - if not body.strip(): - msg = f"Changelog section for version {version} is empty." - raise LookupError(msg) - return body, archive - - msg = f"No changelog section found for version {version}. Expected a heading like: ## [{version}] - YYYY-MM-DD" - raise LookupError(msg) - - -# --------------------------------------------------------------------------- -# Git helpers -# --------------------------------------------------------------------------- - - -def _tag_exists(tag_version: str) -> bool: - """Return ``True`` if *tag_version* already exists as a git tag.""" - try: - run_git_command(["rev-parse", "-q", "--verify", f"refs/tags/{tag_version}"]) - except subprocess.CalledProcessError: - return False - else: - return True - - -def _delete_tag(tag_version: str) -> None: - run_git_command(["tag", "-d", tag_version]) - - -def _github_repo_url(path: str) -> str: - """Return a canonical GitHub URL for a validated two-component path.""" - normalized = path.strip("/").removesuffix(".git") - components = normalized.split("/") - if ( - len(components) != 2 - or any(component in {"", ".", ".."} for component in components) - or any(_GITHUB_REPO_COMPONENT_RE.fullmatch(component) is None for component in components) - ): - msg = "Origin remote must identify exactly one GitHub owner and repository." - raise ValueError(msg) - return f"https://github.com/{components[0]}/{components[1]}" - - -def _get_repo_url() -> str: - """Detect and validate the public GitHub HTTPS URL for ``origin``.""" - result = run_git_command(["remote", "get-url", "origin"]) - raw = result.stdout.strip() - if not raw or any(character.isspace() for character in raw): - msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." - raise ValueError(msg) - - scp_match = _GITHUB_SCP_REMOTE_RE.fullmatch(raw) - if scp_match is not None: - path = scp_match.group("path") - if "?" in path or "#" in path: - msg = "Origin remote must not include query parameters or fragments." - raise ValueError(msg) - return _github_repo_url(path) - - try: - parsed = urlsplit(raw) - hostname = parsed.hostname - port = parsed.port - except ValueError as err: - msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." - raise ValueError(msg) from err - - if parsed.query or parsed.fragment: - msg = "Origin remote must not include query parameters or fragments." - raise ValueError(msg) - if parsed.password is not None or (parsed.scheme == "https" and parsed.username is not None): - msg = "Origin remote must not contain credentials." - raise ValueError(msg) - if parsed.scheme == "ssh" and parsed.username not in {None, "git"}: - msg = "Origin remote must not contain credentials other than the standard SSH user." - raise ValueError(msg) - if ( - parsed.scheme not in {"https", "ssh"} - or hostname is None - or hostname.casefold() != "github.com" - or port is not None - or (parsed.scheme == "ssh" and parsed.username != "git") - ): - msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." - raise ValueError(msg) - - return _github_repo_url(parsed.path) - - -def _version_header_re(version: str) -> re.Pattern[str]: - """Build the header regex for *version*, matching ``extract_changelog_section``.""" - return re.compile(rf"^##\s*\[?v?{re.escape(version)}\]?(?:$|\s|\()") - - -def _heading_to_anchor(heading_line: str) -> str: - """Convert a markdown heading line to a GitHub-compatible anchor slug.""" - heading = heading_line.removeprefix("## ").strip() - heading = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", heading) - heading = re.sub(r"\[([^\]]+)\]", r"\1", heading) - heading = heading.lower() - heading = re.sub(r"[^a-z0-9\s-]", "", heading) - return re.sub(r"\s+", "-", heading) - - -def _find_anchor_in_file(path: Path, version: str) -> str | None: - """Search *path* for a version heading and return its GitHub anchor.""" - header_re = _version_header_re(version) - try: - for line in path.read_text(encoding="utf-8").splitlines(): - if header_re.match(line): - return _heading_to_anchor(line) - except OSError: - pass - return None - - -def _github_anchor(changelog: Path, version: str) -> str: - """Build a GitHub-compatible heading anchor (matches ``github-slugger``).""" - anchor = _find_anchor_in_file(changelog, version) - if anchor: - return anchor - - archive = _archive_path_for_version(changelog, version) - if archive: - anchor = _find_anchor_in_file(archive, version) - if anchor: - return anchor - - return re.sub(r"[^a-z0-9-]", "", f"v{version}".lower()) - - -# --------------------------------------------------------------------------- -# Core workflow -# --------------------------------------------------------------------------- - - -def create_tag(tag_version: str, *, force: bool = False) -> None: - """Create an annotated git tag with changelog content. - - If the changelog section exceeds GitHub's 125KB limit, creates the tag - with a short reference message instead. - """ - version, changelog = _validated_release_target(tag_version) - - # Check for existing tag (but don't delete yet โ€” validate first) - tag_existed = _tag_exists(tag_version) - if tag_existed and not force: - print(f"{_YELLOW}Tag '{tag_version}' already exists.{_RESET}", file=sys.stderr) - print(f"Use --force to recreate, or delete manually: git tag -d {tag_version}", file=sys.stderr) - sys.exit(1) - - # Extract changelog section (before any mutation) - section, source = extract_changelog_section(changelog, version) - section_bytes = len(section.encode("utf-8")) - - # Check size limit - if section_bytes > _GITHUB_TAG_ANNOTATION_LIMIT: - print(f"{_YELLOW}โš  Changelog section ({section_bytes:,} bytes) exceeds GitHub's tag limit ({_GITHUB_TAG_ANNOTATION_LIMIT:,} bytes){_RESET}") - anchor = _github_anchor(changelog, version) - repo_url = _get_repo_url() - try: - source_rel = source.relative_to(changelog.parent) - except ValueError: - source_rel = source - source_rel_posix = source_rel.as_posix() - tag_message = ( - f"Version {version}\n\n" - f"This release contains extensive changes. See full changelog:\n" - f"<{repo_url}/blob/{tag_version}/{source_rel_posix}#{anchor}>\n\n" - f"For detailed release notes, refer to {source_rel_posix} in the repository.\n" - ) - is_truncated = True - print(f"{_BLUE}โ†’ Creating annotated tag with CHANGELOG.md reference{_RESET}") - else: - tag_message = section - is_truncated = False - print(f"{_BLUE}Tag message preview ({section_bytes:,} bytes):{_RESET}") - preview = section.split("\n")[:20] - print("----------------------------------------") - print("\n".join(preview)) - if len(section.split("\n")) > 20: - print("... (truncated for preview)") - print("----------------------------------------") - - # Create annotated tag. Let git replace the ref transactionally instead of - # deleting the existing tag before the replacement object exists. - label = "reference" if is_truncated else "full changelog" - tag_command = ["tag", "-a", tag_version, "-F", "-", "--cleanup=verbatim"] - if tag_existed and force: - print(f"{_BLUE}Replacing existing tag '{tag_version}'...{_RESET}") - tag_command.insert(1, "-f") - - print(f"{_BLUE}Creating annotated tag '{tag_version}' with {label} content...{_RESET}") - run_git_command_with_input(tag_command, input_data=tag_message) - - # Success - print(f"{_GREEN}โœ“ Successfully created tag '{tag_version}'{_RESET}") - print() - print("Next steps:") - if force: - print(f" 1. Force-push the tag: {_BLUE}git push --force origin {tag_version}{_RESET}") - else: - print(f" 1. Push the tag: {_BLUE}git push origin {tag_version}{_RESET}") - print(f" 2. Create GitHub release: {_BLUE}gh release create {tag_version} --title {tag_version} --notes-from-tag{_RESET}") - if is_truncated: - print(f"\n{_YELLOW}Note: Tag annotation references CHANGELOG.md due to size (>125KB).{_RESET}") - - -# --------------------------------------------------------------------------- -# CLI entry point -# --------------------------------------------------------------------------- - - -def main(argv: list[str] | None = None) -> int: - """CLI entry point for ``tag-release``.""" - parser = argparse.ArgumentParser( - prog="tag-release", - description="Create an annotated git tag from a CHANGELOG.md section.", - ) - parser.add_argument("version", help="Tag version (e.g. v1.2.3)") - parser.add_argument("--force", action="store_true", help="Recreate tag if it already exists") - parser.add_argument("--debug", action="store_true", help="Enable debug logging") - args = parser.parse_args(argv) - - if args.debug: - logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") - else: - logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s") - - try: - create_tag(args.version, force=args.force) - except ( - TypeError, - ValueError, - FileNotFoundError, - LookupError, - ExecutableNotFoundError, - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - ) as exc: - print(f"Error: {format_exception_diagnostics(exc)}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/tests/test_archive_changelog.py b/scripts/tests/test_archive_changelog.py deleted file mode 100644 index 08efed7..0000000 --- a/scripts/tests/test_archive_changelog.py +++ /dev/null @@ -1,715 +0,0 @@ -"""Tests for archive_changelog.py โ€” parsing, grouping, split/archive, and idempotency.""" - -import logging -from typing import TYPE_CHECKING - -import pytest - -import archive_changelog as archive_changelog_module -from archive_changelog import ( - _extract_link_defs, - _format_link_defs, - _minor_key, - _version_sort_key, - archive_changelog, - build_root, - group_by_minor, - parse_changelog, - write_archive, -) -from tag_release import _github_anchor, extract_changelog_section - -if TYPE_CHECKING: - from pathlib import Path - - -# --------------------------------------------------------------------------- -# Fixtures / helpers -# --------------------------------------------------------------------------- - -_PREAMBLE = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" - -_UNRELEASED = "## [Unreleased]\n\n### Added\n\n- Something new\n\n" - -_V072 = "## [0.7.2] - 2026-03-10\n\n### Fixed\n\n- Bug fix in 0.7.2\n\n" - -_V071 = "## [0.7.1] - 2026-02-20\n\n### Changed\n\n- Change in 0.7.1\n\n" - -_V062 = "## [0.6.2] - 2026-01-01\n\n### Maintenance\n\n- Bump dep in 0.6.2\n\n" - -_V061 = "## [0.6.1] - 2025-12-17\n\n### Added\n\n- Feature in 0.6.1\n\n" - -_V020 = "## [0.2.0] - 2024-09-13\n\n### Added\n\n- Initial release\n" - -_LINK_DEFS = ( - "\n" - "[unreleased]: https://github.com/acgetchell/delaunay/compare/v0.7.2..HEAD\n" - "[0.7.2]: https://github.com/acgetchell/delaunay/compare/v0.7.1..v0.7.2\n" - "[0.7.1]: https://github.com/acgetchell/delaunay/compare/v0.7.0..v0.7.1\n" - "[0.6.2]: https://github.com/acgetchell/delaunay/compare/v0.6.1..v0.6.2\n" - "[0.6.1]: https://github.com/acgetchell/delaunay/compare/v0.6.0..v0.6.1\n" - "[0.2.0]: https://github.com/acgetchell/delaunay/tree/v0.2.0\n" -) - - -def _full_changelog() -> str: - return _PREAMBLE + _UNRELEASED + _V072 + _V071 + _V062 + _V061 + _V020 - - -def _full_changelog_with_links() -> str: - return _full_changelog() + _LINK_DEFS - - -# --------------------------------------------------------------------------- -# Unit tests -# --------------------------------------------------------------------------- - - -class TestMinorKey: - def test_simple(self) -> None: - assert _minor_key("0.7.2") == "0.7" - - def test_prerelease(self) -> None: - assert _minor_key("1.2.3-rc.1") == "1.2" - - def test_major(self) -> None: - assert _minor_key("2.0.0") == "2.0" - - def test_malformed_single_component(self) -> None: - with pytest.raises(ValueError, match="at least two components"): - _minor_key("1") - - def test_malformed_empty_string(self) -> None: - with pytest.raises(ValueError, match="at least two components"): - _minor_key("") - - -class TestVersionSortKey: - def test_numeric_ordering(self) -> None: - labels = ["0.2.0", "0.10.0", "0.9.0", "0.7.2"] - assert sorted(labels, key=_version_sort_key) == [ - "0.2.0", - "0.7.2", - "0.9.0", - "0.10.0", - ] - - def test_minor_keys(self) -> None: - minors = ["0.2", "0.10", "0.9", "0.7"] - assert sorted(minors, key=_version_sort_key, reverse=True) == [ - "0.10", - "0.9", - "0.7", - "0.2", - ] - - def test_unreleased_sorts_last(self) -> None: - labels = ["0.7.2", "unreleased", "0.6.1"] - assert sorted(labels, key=_version_sort_key) == [ - "0.6.1", - "0.7.2", - "unreleased", - ] - - def test_reverse_unreleased_first(self) -> None: - labels = ["0.7.2", "unreleased", "0.6.1"] - assert sorted(labels, key=_version_sort_key, reverse=True) == [ - "unreleased", - "0.7.2", - "0.6.1", - ] - - def test_prerelease_labels_stay_semantic(self) -> None: - labels = ["1.2.3", "1.2.3-rc.10", "1.2.3-rc.2", "1.2.3-alpha.1", "unreleased"] - assert sorted(labels, key=_version_sort_key) == [ - "1.2.3-alpha.1", - "1.2.3-rc.2", - "1.2.3-rc.10", - "1.2.3", - "unreleased", - ] - - def test_build_metadata_is_ignored_for_sorting(self) -> None: - labels = ["1.2.3-rc.1+build.7", "1.2.3+build.7", "1.2.3-alpha.1+build.7"] - assert sorted(labels, key=_version_sort_key) == [ - "1.2.3-alpha.1+build.7", - "1.2.3-rc.1+build.7", - "1.2.3+build.7", - ] - - -class TestParseChangelog: - def test_splits_preamble_unreleased_versions(self) -> None: - preamble, unreleased, blocks = parse_changelog(_full_changelog()) - assert "# Changelog" in preamble - assert "Unreleased" in unreleased - assert len(blocks) == 5 - assert blocks[0][0] == "0.7.2" - assert blocks[-1][0] == "0.2.0" - - def test_no_headings(self) -> None: - preamble, unreleased, blocks = parse_changelog("Just some text\n") - assert preamble == "Just some text\n" - assert unreleased == "" - assert blocks == [] - - def test_no_unreleased(self) -> None: - text = _PREAMBLE + _V072 + _V071 - _, unreleased, blocks = parse_changelog(text) - assert unreleased == "" - assert len(blocks) == 2 - - def test_rejects_non_semver_headings(self) -> None: - text = _PREAMBLE + _V072 + "## [CustomLabel]\n\n- Something\n\n" + _V071 - - with pytest.raises(ValueError, match="Unrecognized changelog version heading"): - parse_changelog(text) - - def test_rejects_unreleased_heading_without_closing_bracket_boundary(self) -> None: - text = _PREAMBLE + "## [Unreleased]invalid\n\n- Something\n\n" + _V072 - - with pytest.raises(ValueError, match="Unrecognized changelog version heading"): - parse_changelog(text) - - @pytest.mark.parametrize("version", ["01.2.3", "1.02.3", "1.2.03", "1.2.3garbage", "1.2.3-01"]) - def test_rejects_malformed_semver_headings(self, version: str) -> None: - text = _PREAMBLE + f"## [{version}] - 2026-01-01\n" - - with pytest.raises(ValueError, match="semantic version"): - parse_changelog(text) - - def test_rejects_duplicate_unreleased_headings(self) -> None: - with pytest.raises(ValueError, match="Duplicate Unreleased"): - parse_changelog(_PREAMBLE + _UNRELEASED + _UNRELEASED + _V072) - - def test_rejects_duplicate_release_headings(self) -> None: - with pytest.raises(ValueError, match="Duplicate changelog version"): - parse_changelog(_PREAMBLE + _V072 + _V072) - - -class TestGroupByMinor: - def test_groups_correctly(self) -> None: - _, _, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - assert list(groups.keys()) == ["0.7", "0.6", "0.2"] - assert len(groups["0.7"]) == 2 - assert len(groups["0.6"]) == 2 - assert len(groups["0.2"]) == 1 - - -class TestExtractLinkDefs: - def test_extracts_trailing_defs(self) -> None: - text = _full_changelog_with_links() - cleaned, link_defs = _extract_link_defs(text) - assert "unreleased" in link_defs - assert "0.7.2" in link_defs - assert "0.2.0" in link_defs - assert len(link_defs) == 6 - # Cleaned text should not contain any link defs. - assert "[unreleased]:" not in cleaned - assert "[0.7.2]:" not in cleaned - - def test_no_link_defs(self) -> None: - cleaned, link_defs = _extract_link_defs(_full_changelog()) - assert link_defs == {} - assert cleaned == _full_changelog() - - def test_preserves_content_before_defs(self) -> None: - text = _full_changelog_with_links() - cleaned, _ = _extract_link_defs(text) - # All version headings should still be present. - assert "## [0.7.2]" in cleaned - assert "## [0.2.0]" in cleaned - assert "## [Unreleased]" in cleaned - - -class TestWriteArchive: - def test_writes_archive_file(self, tmp_path: Path) -> None: - _, _, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - path = write_archive(tmp_path, "0.6", groups["0.6"]) - assert path.name == "0.6.md" - content = path.read_text(encoding="utf-8") - assert content.startswith("# Changelog - 0.6.x\n") - assert "## [0.6.2]" in content - assert "## [0.6.1]" in content - assert content.endswith("\n") - - def test_creates_directory(self, tmp_path: Path) -> None: - nested = tmp_path / "a" / "b" - write_archive(nested, "0.2", [("0.2.0", _V020)]) - assert (nested / "0.2.md").is_file() - - def test_includes_relevant_link_defs(self, tmp_path: Path) -> None: - _, link_defs = _extract_link_defs(_full_changelog_with_links()) - _, _, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - path = write_archive(tmp_path, "0.6", groups["0.6"], link_defs) - content = path.read_text(encoding="utf-8") - # Should include only 0.6.x link defs. - assert "[0.6.2]:" in content - assert "[0.6.1]:" in content - # Should NOT include defs for other versions. - assert "[0.7.2]:" not in content - assert "[unreleased]:" not in content - assert "[0.2.0]:" not in content - - def test_postprocesses_archived_blocks(self, tmp_path: Path) -> None: - block = ( - "## [0.5.0] - 2025-01-01\n\n" - "### Fixed\n\n" - "- Fix remove_vertex topology consistency [#124](https://github.com/acgetchell/delaunay/pull/124)\n" - " [`da473c8`](https://github.com/acgetchell/delaunay/commit/da473c8deadbeef)\n\n" - " This commit addresses three critical issues:\n\n" - " 1. **Fix remove_vertex to maintain topology consistency**\n\n" - " - Added logic to clear dangling neighbor references\n" - ) - path = write_archive(tmp_path, "0.5", [("0.5.0", block)]) - content = path.read_text(encoding="utf-8") - assert "\n - Added logic to clear dangling neighbor references\n" in content - assert "\n - Added logic to clear dangling neighbor references\n" not in content - - -class TestBuildRoot: - def test_includes_active_and_archives(self) -> None: - preamble, unreleased, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - root = build_root( - preamble, - unreleased, - groups["0.7"], - sorted(["0.6", "0.2"], reverse=True), - "docs/archive/changelog", - ) - assert "## [Unreleased]" in root - assert "## [0.7.2]" in root - assert "## [0.7.1]" in root - assert "## [0.6.2]" not in root - assert "## Archives" in root - assert "[0.6.x](docs/archive/changelog/0.6.md)" in root - assert "[0.2.x](docs/archive/changelog/0.2.md)" in root - - def test_no_archives_when_empty(self) -> None: - root = build_root("# H\n", "", [("1.0.0", _V072)], [], "archive") - assert "## Archives" not in root - - def test_no_link_defs_by_default(self) -> None: - """build_root does not emit link defs (handled by orchestrator).""" - preamble, unreleased, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - root = build_root( - preamble, - unreleased, - groups["0.7"], - ["0.6", "0.2"], - "docs/archive/changelog", - ) - assert "[unreleased]:" not in root - assert "[0.7.2]:" not in root - - -# --------------------------------------------------------------------------- -# Integration / workflow tests -# --------------------------------------------------------------------------- - - -class TestArchiveChangelog: - def test_out_of_order_releases_preserve_root_and_archives(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - original = _PREAMBLE + _V071 + _V072 + _V062 - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.6.md" - existing = b"# Existing archive\r\n" - existing_archive.write_bytes(existing) - - with pytest.raises(ValueError, match="strictly descending semantic-version order"): - archive_changelog(changelog, archive_dir) - - assert changelog.read_text(encoding="utf-8") == original - assert existing_archive.read_bytes() == existing - assert sorted(path.name for path in archive_dir.iterdir()) == ["0.6.md"] - - def test_cli_reports_order_error_without_traceback( - self, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - original = _PREAMBLE + _V071 + _V072 + _V062 - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "archive" - - status = archive_changelog_module.main([str(changelog), "--archive-dir", str(archive_dir)]) - - captured = capsys.readouterr() - assert status == 1 - assert "strictly descending semantic-version order" in captured.err - assert "Traceback" not in captured.err - assert changelog.read_text(encoding="utf-8") == original - assert not archive_dir.exists() - - def test_unknown_heading_preserves_root_and_archives(self, tmp_path: Path) -> None: - """An unknown version-like heading fails before any output is rewritten.""" - changelog = tmp_path / "CHANGELOG.md" - original = _PREAMBLE + _V072 + "## [CustomLabel]\n\n- Preserve me\n\n" + _V062 - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.5.md" - existing = "# Changelog - 0.5.x\n\nHistorical content\n" - existing_archive.write_text(existing, encoding="utf-8") - - with pytest.raises(ValueError, match="CustomLabel"): - archive_changelog(changelog, archive_dir) - - assert changelog.read_text(encoding="utf-8") == original - assert existing_archive.read_text(encoding="utf-8") == existing - assert sorted(path.name for path in archive_dir.iterdir()) == ["0.5.md"] - - def test_splits_and_archives(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - - # Root should only contain 0.7.x + Archives. - root = changelog.read_text(encoding="utf-8") - assert "## [0.7.2]" in root - assert "## [0.7.1]" in root - assert "## [0.6.2]" not in root - assert "## Archives" in root - - # Archive files should exist. - assert (archive_dir / "0.6.md").is_file() - assert (archive_dir / "0.2.md").is_file() - - # Archive content should be verbatim. - a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - assert "## [0.6.2]" in a06 - assert "## [0.6.1]" in a06 - - def test_multi_file_publication_rolls_back_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - changelog = tmp_path / "CHANGELOG.md" - original_root = _full_changelog() - changelog.write_text(original_root, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.6.md" - original_archive = b"# Existing 0.6 archive\r\n" - existing_archive.write_bytes(original_archive) - - def fail_root_publication(source: Path, destination: Path) -> None: - if destination == changelog: - msg = "simulated root publication failure" - raise OSError(msg) - source.replace(destination) - - monkeypatch.setattr("archive_changelog._replace_path", fail_root_publication) - - with pytest.raises(OSError, match="simulated root publication failure"): - archive_changelog(changelog, archive_dir) - - assert changelog.read_text(encoding="utf-8") == original_root - assert existing_archive.read_bytes() == original_archive - assert not (archive_dir / "0.2.md").exists() - - def test_stage_text_removes_temporary_file_when_fsync_fails( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - target = tmp_path / "CHANGELOG.md" - - def fail_fsync(_descriptor: int) -> None: - msg = "simulated fsync failure" - raise OSError(msg) - - monkeypatch.setattr(archive_changelog_module.os, "fsync", fail_fsync) - - with pytest.raises(OSError, match="simulated fsync failure"): - archive_changelog_module._stage_text(target, "payload\n") - - assert not list(tmp_path.glob(".CHANGELOG.md.*.tmp")) - - def test_stage_bytes_removes_temporary_file_when_fsync_fails( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - target = tmp_path / "0.4.md" - - def fail_fsync(_descriptor: int) -> None: - msg = "simulated fsync failure" - raise OSError(msg) - - monkeypatch.setattr(archive_changelog_module.os, "fsync", fail_fsync) - - with pytest.raises(OSError, match="simulated fsync failure"): - archive_changelog_module._stage_bytes(target, b"payload\n") - - assert not list(tmp_path.glob(".0.4.md.*.tmp")) - - def test_partial_staging_failure_removes_prior_temporary_files( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - first = tmp_path / "first.md" - second = tmp_path / "second.md" - real_stage_text = archive_changelog_module._stage_text - - def fail_second_stage(path: Path, text: str) -> Path: - if path == second: - msg = "simulated second staging failure" - raise OSError(msg) - return real_stage_text(path, text) - - monkeypatch.setattr(archive_changelog_module, "_stage_text", fail_second_stage) - - with pytest.raises(OSError, match="simulated second staging failure"): - archive_changelog_module._publish_texts( - { - first: "first\n", - second: "second\n", - } - ) - - assert not first.exists() - assert not second.exists() - assert not list(tmp_path.glob(".*.tmp")) - - def test_idempotent(self, tmp_path: Path) -> None: - """Running archive twice produces the same output.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - first_root = changelog.read_text(encoding="utf-8") - first_a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - - archive_changelog(changelog, archive_dir) - second_root = changelog.read_text(encoding="utf-8") - second_a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - - assert first_root == second_root - assert first_a06 == second_a06 - - def test_single_minor_no_op(self, tmp_path: Path) -> None: - """When only one minor series exists, nothing is archived.""" - changelog = tmp_path / "CHANGELOG.md" - text = _PREAMBLE + _UNRELEASED + _V072 + _V071 - changelog.write_text(text, encoding="utf-8") - - archive_changelog(changelog, tmp_path / "archive") - # File should be unchanged. - assert changelog.read_text(encoding="utf-8") == text - assert not (tmp_path / "archive").exists() - - def test_no_versions_no_op(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n\nNo versions yet.\n", encoding="utf-8") - archive_changelog(changelog, tmp_path / "archive") - assert changelog.read_text(encoding="utf-8") == "# Changelog\n\nNo versions yet.\n" - - def test_existing_archives_are_postprocessed(self, tmp_path: Path) -> None: - """Older archive files are normalized even when they are not regenerated.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _UNRELEASED + _V072 + _V071, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - archive = archive_dir / "0.5.md" - archive.write_text( - "# Changelog - 0.5.x\n\n" - "## [0.5.3] - 2025-10-31\n\n" - "### Fixed\n\n" - "- Handle degenerate configurations [#116](https://github.com/acgetchell/causal-triangulations/pull/116)\n" - " [`a6ec3fa`](https://github.com/acgetchell/causal-triangulations/commit/a6ec3fadeadbeef)\n\n" - "## Duplicate Vertex Handling\n\n" - "- Add duplicate coordinate detection\n", - encoding="utf-8", - ) - - archive_changelog(changelog, archive_dir) - - content = archive.read_text(encoding="utf-8") - assert "\n## Duplicate Vertex Handling" not in content - assert "#### Duplicate Vertex Handling" in content - - def test_distributes_link_defs(self, tmp_path: Path) -> None: - """Reference-style link definitions are distributed to the correct files.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog_with_links(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - - root = changelog.read_text(encoding="utf-8") - # Root should have active + unreleased link defs. - assert "[unreleased]:" in root - assert "[0.7.2]:" in root - assert "[0.7.1]:" in root - # Root should NOT have archived version defs. - assert "[0.6.2]:" not in root - assert "[0.2.0]:" not in root - - # Archive files should only contain their own version defs. - a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - assert "[0.6.2]:" in a06 - assert "[0.6.1]:" in a06 - assert "[0.7.2]:" not in a06 - assert "[unreleased]:" not in a06 - - a02 = (archive_dir / "0.2.md").read_text(encoding="utf-8") - assert "[0.2.0]:" in a02 - assert "[0.7.2]:" not in a02 - - def test_idempotent_with_link_defs(self, tmp_path: Path) -> None: - """Idempotency holds when link definitions are present.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog_with_links(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - first_root = changelog.read_text(encoding="utf-8") - first_a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - - archive_changelog(changelog, archive_dir) - assert changelog.read_text(encoding="utf-8") == first_root - assert (archive_dir / "0.6.md").read_text(encoding="utf-8") == first_a06 - - def test_archive_dir_outside_changelog_tree_uses_relative_link( - self, - tmp_path: Path, - caplog: pytest.LogCaptureFixture, - ) -> None: - changelog_dir = tmp_path / "repo" - changelog_dir.mkdir() - changelog = changelog_dir / "CHANGELOG.md" - changelog.write_text(_full_changelog(), encoding="utf-8") - archive_dir = tmp_path / "outside" / "archive" - - with caplog.at_level(logging.WARNING, logger="archive_changelog"): - archive_changelog(changelog, archive_dir) - - root = changelog.read_text(encoding="utf-8") - assert "- [0.6.x](../outside/archive/0.6.md)" in root - assert str(archive_dir) in caplog.text - assert str(changelog_dir) in caplog.text - - def test_archive_dir_relpath_value_error_preserves_changelog( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Cross-volume paths fail without publishing absolute archive links.""" - changelog_dir = tmp_path / "repo" - changelog_dir.mkdir() - changelog = changelog_dir / "CHANGELOG.md" - original = _full_changelog() - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "outside" / "archive" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.5.md" - existing = "# Changelog - 0.5.x\n\nHistorical content\n" - existing_archive.write_text(existing, encoding="utf-8") - - def raise_cross_drive_value_error(_path: Path, _start: Path) -> str: - msg = "path is on mount 'D:', start on mount 'C:'" - raise ValueError(msg) - - monkeypatch.setattr("archive_changelog.os.path.relpath", raise_cross_drive_value_error) - - with pytest.raises(ValueError, match="different filesystem roots") as exc_info: - archive_changelog(changelog, archive_dir) - - root = changelog.read_text(encoding="utf-8") - assert root == original - assert str(archive_dir) not in root - assert archive_dir.as_posix() not in root - assert str(archive_dir) not in str(exc_info.value) - assert archive_dir.as_posix() not in str(exc_info.value) - assert isinstance(exc_info.value.__cause__, ValueError) - assert existing_archive.read_text(encoding="utf-8") == existing - assert sorted(path.name for path in archive_dir.iterdir()) == ["0.5.md"] - - -# --------------------------------------------------------------------------- -# tag_release archive fallback -# --------------------------------------------------------------------------- - - -class TestFormatLinkDefs: - def test_semver_ordering_with_double_digit_minor(self) -> None: - """Versions like 0.10.x sort after 0.9.x, not before.""" - link_defs = { - "0.10.0": "[0.10.0]: https://example.com/compare/v0.9.0..v0.10.0", - "0.9.0": "[0.9.0]: https://example.com/compare/v0.8.0..v0.9.0", - "0.7.10": "[0.7.10]: https://example.com/compare/v0.7.9..v0.7.10", - "0.7.2": "[0.7.2]: https://example.com/compare/v0.7.1..v0.7.2", - } - labels = {"0.10.0", "0.9.0", "0.7.10", "0.7.2"} - result = _format_link_defs(link_defs, labels) - lines = result.split("\n") - # Newest (0.10.0) first, then 0.9.0, 0.7.10, 0.7.2. - assert lines[0].startswith("[0.10.0]:") - assert lines[1].startswith("[0.9.0]:") - assert lines[2].startswith("[0.7.10]:") - assert lines[3].startswith("[0.7.2]:") - - def test_unreleased_sorts_first_in_reverse(self) -> None: - link_defs = { - "unreleased": "[unreleased]: https://example.com/compare/v0.7.2..HEAD", - "0.7.2": "[0.7.2]: https://example.com/compare/v0.7.1..v0.7.2", - } - result = _format_link_defs(link_defs, {"unreleased", "0.7.2"}) - lines = result.split("\n") - assert lines[0].startswith("[unreleased]:") - assert lines[1].startswith("[0.7.2]:") - - -class TestTagReleaseArchiveFallback: - def test_extract_from_archive(self, tmp_path: Path) -> None: - """extract_changelog_section falls back to archive when version not in root.""" - # Write a root changelog without 0.6.x. - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _V072, encoding="utf-8") - - # Write an archive with 0.6.x. - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - (archive_dir / "0.6.md").write_text( - "# Changelog - 0.6.x\n\n" + _V062 + _V061, - encoding="utf-8", - ) - - body, source = extract_changelog_section(changelog, "0.6.2") - assert "Bump dep in 0.6.2" in body - assert source == archive_dir / "0.6.md" - - def test_extract_from_root_returns_root_source(self, tmp_path: Path) -> None: - """extract_changelog_section returns the root changelog as source when found there.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _V072, encoding="utf-8") - - body, source = extract_changelog_section(changelog, "0.7.2") - assert "Bug fix in 0.7.2" in body - assert source == changelog - - def test_anchor_from_archive(self, tmp_path: Path) -> None: - """_github_anchor falls back to archive for archived versions.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _V072, encoding="utf-8") - - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - (archive_dir / "0.6.md").write_text( - "# Changelog - 0.6.x\n\n" + _V062, - encoding="utf-8", - ) - - anchor = _github_anchor(changelog, "0.6.2") - assert "062" in anchor diff --git a/scripts/tests/test_changelog_integration.py b/scripts/tests/test_changelog_integration.py new file mode 100644 index 0000000..a6dcef1 --- /dev/null +++ b/scripts/tests/test_changelog_integration.py @@ -0,0 +1,295 @@ +"""Consumer integration with the pinned, published changelog CLI.""" + +import json +import re +import shutil +import tomllib +from datetime import UTC, datetime +from importlib.metadata import version +from pathlib import Path + +import pytest +from research_repo_tools.cli import main + +from subprocess_utils import run_git_command, run_safe_command + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def consumer(tmp_path: Path) -> Path: + """Use real consumer configuration and policy in a disposable Git repository.""" + for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "Cargo.toml", "README.md"): + shutil.copyfile(REPO_ROOT / name, tmp_path / name) + run_git_command(["init", "--quiet"], cwd=tmp_path) + run_git_command(["config", "user.name", "Changelog fixture"], cwd=tmp_path) + run_git_command(["config", "user.email", "fixture@example.invalid"], cwd=tmp_path) + return tmp_path + + +def cli(root: Path, *args: str) -> int: + """Call only the documented installed-package entry point.""" + return main(["--root", str(root), "changelog", *args]) + + +def markdown_bytes(root: Path) -> dict[Path, bytes]: + """Capture changelog outputs for read-only and failed-publication assertions.""" + paths = [root / "CHANGELOG.md", *(root / "docs/archives/changelog").glob("*.md")] + return {path.relative_to(root): path.read_bytes() for path in paths if path.is_file()} + + +def commit(root: Path, message: str) -> None: + """Append one real commit to the fixture history.""" + run_git_command(["commit", "--quiet", "--allow-empty", "-m", message], cwd=root) + + +def test_published_package_is_exactly_pinned_and_included_in_dev() -> None: + """Normal sync installs a PyPI release, with no sibling or editable override.""" + manifest = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + groups = manifest["dependency-groups"] + assert groups["tooling"] == ["research-repo-tools==0.1.7"] + assert {"include-group": "tooling"} in groups["dev"] + assert version("research-repo-tools") == "0.1.7" + lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + package = next(package for package in lock["package"] if package["name"] == "research-repo-tools") + assert package["version"] == "0.1.7" + assert package["source"] == {"registry": "https://pypi.org/simple"} + + +def test_archive_rotation_preserves_notes_references_and_relative_links(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Root and archived notes remain discoverable after a minor-series rotation.""" + changelog = consumer / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## [Unreleased]\n\n- Pending.\n\n" + "## [0.5.0] - 2026-09-30\n\n- New minor.\n\n" + "## [0.4.6] - 2026-09-08\n\n- Keep `Vector<D>` and [guide][guide].\n\n" + "## [0.4.5] - 2026-08-21\n\n- Prior patch.\n\n" + "[guide]: README.md\n" + "[0.5.0]: https://github.com/acgetchell/la-stack/compare/v0.4.6...v0.5.0\n" + "[0.4.6]: https://github.com/acgetchell/la-stack/compare/v0.4.5...v0.4.6\n", + encoding="utf-8", + ) + assert cli(consumer, "archive") == 0 + root = changelog.read_text(encoding="utf-8") + archive = consumer / "docs/archives/changelog/0.4.md" + archived = archive.read_text(encoding="utf-8") + assert "## [Unreleased]" in root + assert "## [0.5.0] - 2026-09-30" in root + assert "## [0.4.6]" not in root + assert "[0.4.x](docs/archives/changelog/0.4.md)" in root + assert "## [0.4.6] - 2026-09-08" in archived + assert "## [0.4.5] - 2026-08-21" in archived + assert "`Vector<D>`" in archived + assert "[guide]: ../../../README.md" in archived + before = markdown_bytes(consumer) + assert cli(consumer, "archive") == 0 + assert cli(consumer, "check") == 0 + assert markdown_bytes(consumer) == before + capsys.readouterr() + assert cli(consumer, "notes", "v0.4.6") == 0 + notes = capsys.readouterr().out + assert "Keep `Vector<D>`" in notes + assert "[guide]: ../../../README.md" in notes + assert "Prior patch" not in notes + assert "[0.5.0]:" not in notes + assert cli(consumer, "notes", "v0.5.0") == 0 + assert "New minor" in capsys.readouterr().out + assert markdown_bytes(consumer) == before + + +def test_generation_uses_consumer_policy_dates_and_transactional_preview(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Real Git/git-cliff generation retains authored dependency links and explicit dates.""" + commit(consumer, "feat: add exact arithmetic") + run_git_command(["tag", "v0.4.5"], cwd=consumer) + commit( + consumer, + "chore(deps-dev): bump ruff from 0.16.1 to 0.16.2\n\nRead [release notes](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2).", + ) + commit(consumer, "fix!: preserve matrix invariants (#42)\n\nBREAKING CHANGE: preserve exact errors and migration instructions.") + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.5] - 2000-01-02\n\n- Authored date.\n", encoding="utf-8") + metadata_before = {name: (consumer / name).read_bytes() for name in ("Cargo.toml", "pyproject.toml")} + before = markdown_bytes(consumer) + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30", "--dry-run") == 0 + preview = capsys.readouterr().out + assert "## [0.5.0] - 2026-09-30" in preview + assert "https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2" in preview + assert "https://github.com/acgetchell/la-stack/pull/42" in preview + assert "preserve exact errors and migration instructions" in preview + assert markdown_bytes(consumer) == before + assert not (consumer / "docs/archives/changelog").exists() + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30") == 0 + assert (consumer / "CHANGELOG.md").read_text(encoding="utf-8") == preview + archive = consumer / "docs/archives/changelog/0.4.md" + assert "## [0.4.5] - 2000-01-02" in archive.read_text(encoding="utf-8") + assert {name: (consumer / name).read_bytes() for name in metadata_before} == metadata_before + generated = markdown_bytes(consumer) + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30") == 0 + assert markdown_bytes(consumer) == generated + + +@pytest.mark.parametrize( + ("args", "diagnostic"), + [ + (("generate", "--tag", "v0.5.0"), "both --tag and --date"), + (("generate", "--date", "2026-09-30"), "both --tag and --date"), + (("generate", "--tag", "v0.5.0", "--date", "2026-02-30"), "day"), + (("generate", "--tag", "v01.5.0", "--date", "2026-09-30"), "SemVer format"), + (("notes", "0.4.6"), "SemVer format"), + (("notes", "v9.9.9"), "not found"), + ], +) +def test_cli_errors_are_actionable_and_preserve_outputs(consumer: Path, capsys: pytest.CaptureFixture[str], args: tuple[str, ...], diagnostic: str) -> None: + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.6] - 2026-09-08\n\n- Retained.\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, *args) == 1 + error = capsys.readouterr().err + assert "research-repo-tools:" in error + assert diagnostic.casefold() in error.casefold() + assert "Traceback" not in error + assert markdown_bytes(consumer) == before + + +@pytest.mark.parametrize( + ("headings", "diagnostic"), + [ + ("## [0.4.5]\n\n- Older.\n\n## [0.4.6]\n\n- Newer.\n", "out of order"), + ("## [0.4.6]\n\n- One.\n\n## [0.4.6]\n\n- Duplicate.\n", "duplicate"), + ("## [0.4.6] - 2026-02-30\n\n- Invalid date.\n", "date"), + ], +) +def test_whole_history_errors_do_not_rotate_files(consumer: Path, capsys: pytest.CaptureFixture[str], headings: str, diagnostic: str) -> None: + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n" + headings, encoding="utf-8") + before = markdown_bytes(consumer) + for command in ("check", "archive"): + assert cli(consumer, command) == 1 + assert diagnostic in capsys.readouterr().err.casefold() + assert markdown_bytes(consumer) == before + + +def test_archive_conflicts_preserve_retained_history(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Changed notes for an already archived release fail before any replacement.""" + (consumer / "CHANGELOG.md").write_text( + "# Changelog\n\n## [0.5.0] - 2026-09-30\n\n- Current.\n\n## [0.4.6] - 2026-09-08\n\n- Conflicting notes.\n", + encoding="utf-8", + ) + archive = consumer / "docs/archives/changelog/0.4.md" + archive.parent.mkdir(parents=True) + archive.write_text("# Changelog - 0.4.x\n\n## [0.4.6] - 2026-09-08\n\n- Authored retained notes.\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "archive") == 1 + assert "conflicting retained release" in capsys.readouterr().err.casefold() + assert markdown_bytes(consumer) == before + + +def test_tag_previews_and_force_preserve_existing_ref_on_error(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Exercise tag and force through the public CLI in a disposable repository only.""" + commit(consumer, "feat: initial fixture") + release_date = datetime.now(UTC).date().isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {release_date}\n\n- Exact release notes.\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "tag", "v0.4.6", "--dry-run") == 0 + assert "Exact release notes" in capsys.readouterr().out + assert run_git_command(["tag", "--list"], cwd=consumer).stdout == "" + assert cli(consumer, "tag", "v0.4.6") == 0 + ref = run_git_command(["rev-parse", "refs/tags/v0.4.6"], cwd=consumer).stdout + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=consumer).stdout.strip() == "tag" + assert cli(consumer, "tag", "v0.4.6") == 1 + assert "already exists" in capsys.readouterr().err + assert cli(consumer, "tag", "v0.5.0", "--force") == 1 + assert "does not match package version" in capsys.readouterr().err + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.6] - 2026-02-30\n\n- Invalid.\n", encoding="utf-8") + assert cli(consumer, "tag", "v0.4.6", "--force") == 1 + assert run_git_command(["rev-parse", "refs/tags/v0.4.6"], cwd=consumer).stdout == ref + (consumer / "CHANGELOG.md").write_bytes(before[Path("CHANGELOG.md")]) + assert cli(consumer, "tag", "v0.4.6", "--force") == 0 + assert markdown_bytes(consumer) == before + + +def test_consumer_recipes_forward_cli_arguments_and_keep_metadata_separate() -> None: + """Check the actual merged recipes and alias, including shell-safe argument forwarding.""" + recipes = json.loads(run_safe_command("just", ["--dump", "--dump-format", "json"], cwd=REPO_ROOT).stdout) + assert recipes["aliases"]["changelog-unreleased"]["target"] == "changelog-release" + commands = { + ("changelog",): "changelog generate", + ("changelog-preview", "--tag", "v0.5.0", "--date", "2026-09-30"): 'generate --dry-run "$@"', + ("changelog-release", "v0.5.0", "2026-09-30"): "generate --tag 'v0.5.0' --date '2026-09-30'", + ("changelog-unreleased", "v0.5.0", "2026-09-30"): "generate --tag 'v0.5.0' --date '2026-09-30'", + ("changelog-archive",): "changelog archive", + ("changelog-check",): "changelog check", + ("release-notes", "v0.4.6"): "changelog notes 'v0.4.6'", + ("tag", "v0.4.6"): "changelog tag 'v0.4.6'", + ("tag-force", "v0.4.6"): "changelog tag 'v0.4.6' --force", + } + for args, expected in commands.items(): + result = run_safe_command("just", ["--dry-run", *args], cwd=REPO_ROOT) + assert expected in result.stderr + assert "uv run --locked --group dev research-repo-tools" in result.stderr + assert "update-release-version" not in result.stderr + + +def test_final_repository_links_and_archived_notes_remain_valid(capsys: pytest.CaptureFixture[str]) -> None: + """Check consumer history, including references in every completed series.""" + assert cli(REPO_ROOT, "check") == 0 + root = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + for relative in re.findall(r"\]\((docs/archives/changelog/[^)]+)\)", root): + assert (REPO_ROOT / relative).is_file() + for tag in ("v0.1.0", "v0.2.0", "v0.3.0", "v0.4.6"): + assert cli(REPO_ROOT, "notes", tag) == 0 + assert capsys.readouterr().out.strip() + policy = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["tool"]["rumdl"] + assert "MD057" not in policy["disable"] + formatter = tomllib.loads((REPO_ROOT / "changelog-rumdl.toml").read_text(encoding="utf-8")) + assert formatter["extends"] == "pyproject.toml" + assert formatter["global"]["extend-disable"] == ["MD057"] + + +def test_tag_preview_checks_citation_date_and_handles_oversized_notes(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Tag annotations validate consumer release dates and retain a full-notes link.""" + commit(consumer, "feat: initial fixture") + released = datetime.now(UTC).date().isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- {'Long notes. ' * 12000}\n", encoding="utf-8") + (consumer / "CITATION.cff").write_text("version: 0.4.6\ndate-released: 2000-01-01\n", encoding="utf-8") + assert cli(consumer, "tag", "v0.4.6", "--dry-run") == 1 + assert "citation.cff date and changelog release date differ" in capsys.readouterr().err.casefold() + (consumer / "CITATION.cff").write_text(f"version: 0.4.6\ndate-released: {released}\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "tag", "v0.4.6", "--dry-run") == 0 + preview = capsys.readouterr().out + assert "https://github.com/acgetchell/la-stack/blob/v0.4.6/CHANGELOG.md#" in preview + assert len(preview.encode("utf-8")) < 125000 + assert markdown_bytes(consumer) == before + assert run_git_command(["tag", "--list"], cwd=consumer).stdout == "" + + +def test_normalization_preserves_fenced_examples_and_prerelease_notes(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Rust code, Markdown links, and fenced release examples remain literal.""" + (consumer / "CHANGELOG.md").write_text( + "# Changelog\n\n## [0.4.6-rc.1+build.7] - 2026-09-30\n\n" + "- Keep `Vector<D>` and [PR](https://github.com/acgetchell/la-stack/pull/42).\n\n" + "```rust\nfn value<T>() -> Vector<T> { todo!() }\n```\n\n" + "~~~text\n## [99.9.9]\n~~~\n", + encoding="utf-8", + ) + assert cli(consumer, "normalize") == 0 + assert cli(consumer, "notes", "v0.4.6-rc.1+build.7") == 0 + notes = capsys.readouterr().out + assert "`Vector<D>`" in notes + assert "fn value<T>() -> Vector<T>" in notes + assert "## [99.9.9]" in notes + assert "https://github.com/acgetchell/la-stack/pull/42" in notes + assert cli(consumer, "check") == 0 + + +def test_formatter_failure_preserves_every_candidate(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A consumer configuration error cannot publish a partial root or archive.""" + commit(consumer, "feat: initial fixture") + run_git_command(["tag", "v0.4.5"], cwd=consumer) + commit(consumer, "fix: add an unreleased change") + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.5] - 2026-08-21\n\n- Retained.\n", encoding="utf-8") + manifest = consumer / "pyproject.toml" + manifest.write_text(manifest.read_text(encoding="utf-8").replace('formatter = "changelog-rumdl.toml"', 'formatter = "missing.toml"'), encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30") == 1 + assert "formatter configuration not found" in capsys.readouterr().err.casefold() + assert markdown_bytes(consumer) == before + assert not (consumer / "docs/archives/changelog").exists() diff --git a/scripts/tests/test_check_docs_version_sync.py b/scripts/tests/test_check_docs_version_sync.py index 35fba90..45ef97a 100644 --- a/scripts/tests/test_check_docs_version_sync.py +++ b/scripts/tests/test_check_docs_version_sync.py @@ -193,9 +193,10 @@ def test_readme_tag_references_accept_semver_suffixes(tmp_path: Path, version: s assert [(reference.line, reference.version) for reference in references] == [(1, version)] -def test_find_version_mismatches_ignores_historical_docs_and_test_fixtures(tmp_path: Path) -> None: +@pytest.mark.parametrize("archive_dir", ["archive", "archives/changelog"]) +def test_find_version_mismatches_ignores_historical_docs_and_test_fixtures(tmp_path: Path, archive_dir: str) -> None: _write_project(tmp_path) - archive = tmp_path / "docs" / "archive" + archive = tmp_path / "docs" / archive_dir archive.mkdir(parents=True) fixtures = tmp_path / "tests" / "fixtures" fixtures.mkdir(parents=True) diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py deleted file mode 100644 index 48f43e5..0000000 --- a/scripts/tests/test_postprocess_changelog.py +++ /dev/null @@ -1,1056 +0,0 @@ -"""Tests for postprocess_changelog.py โ€” trailing blanks, reflow, code blocks, summaries.""" - -import os -from pathlib import Path -from typing import Never - -import pytest - -import postprocess_changelog -from postprocess_changelog import ( - _CodeFence, - _compact_entry, - _inject_summary_sections, - _is_duplicate_squash_heading, - _is_isolated_body_heading, - _max_pr_number, - _normalize_email_autolinks, - _normalize_entry_heading, - _normalize_indented_heading, - _normalize_list_continuation_indent, - _normalize_squash_heading, - _plain_summary, - _process_code_fence, - _reflow_line, - _squash_heading_parts, - _strip_dependabot_metadata, - normalize_entry_headings_text, - postprocess, - postprocess_text, -) -from subprocess_utils import run_safe_command - - -class TestStripTrailingBlanks: - def test_strips_trailing_blank_lines(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item\n\n\n\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n" - - @pytest.mark.skipif(os.name == "nt", reason="POSIX mode preservation is not meaningful on Windows") - def test_atomic_write_preserves_file_mode(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item\n\n\n", encoding="utf-8") - f.chmod(0o640) - - postprocess(f) - - assert f.stat().st_mode & 0o777 == 0o640 - - @pytest.mark.parametrize("failure_point", ["stage", "fsync", "replace"]) - def test_atomic_write_failure_preserves_original( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - failure_point: str, - ) -> None: - f = tmp_path / "CHANGELOG.md" - original = b"# Changelog\n\n- Item\n\n\n" - f.write_bytes(original) - - def fail(*_args: object, **_kwargs: object) -> Never: - msg = f"simulated {failure_point} failure" - raise OSError(msg) - - if failure_point == "stage": - monkeypatch.setattr(postprocess_changelog.tempfile, "NamedTemporaryFile", fail) - elif failure_point == "fsync": - monkeypatch.setattr(postprocess_changelog.os, "fsync", fail) - else: - monkeypatch.setattr(postprocess_changelog.Path, "replace", fail) - - with pytest.raises(OSError, match=f"simulated {failure_point} failure"): - postprocess(f) - - assert f.read_bytes() == original - assert not list(tmp_path.glob(".CHANGELOG.md.*.tmp")) - - def test_preserves_single_trailing_newline(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n" - - def test_adds_trailing_newline_if_missing(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n" - - def test_preserves_internal_blank_lines(self, tmp_path: Path) -> None: - content = "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n\n\n\n" - f = tmp_path / "CHANGELOG.md" - f.write_text(content, encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert result == "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n" - - def test_single_newline_file(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "\n" - - def test_empty_file(self, tmp_path: Path) -> None: - """ - Verifies that processing an empty changelog file results in a file containing exactly one newline. - - Creates an empty CHANGELOG.md at the provided temporary path, runs postprocess on it, and asserts the file's contents are "\n". - """ - f = tmp_path / "CHANGELOG.md" - f.write_text("", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "\n" - - -class TestDependabotMetadata: - def test_strips_yaml_footer_from_commit_body(self) -> None: - content = ( - "- Update setuptools [`abcdef0`](https://example.com/commit/abcdef0)\n\n" - " Updates setuptools to 83.0.0\n\n" - "---\n\n" - " updated-dependencies:\n\n" - "- dependency-name: setuptools\n" - " dependency-version: 83.0.0\n" - " dependency-type: direct:development\n" - " ...\n" - "- Next entry\n" - ) - - assert _strip_dependabot_metadata(content) == ( - "- Update setuptools [`abcdef0`](https://example.com/commit/abcdef0)\n\n Updates setuptools to 83.0.0\n\n- Next entry\n" - ) - - def test_preserves_unrelated_thematic_break(self) -> None: - content = "- Entry\n\n---\n\nAdditional context\n" - - assert _strip_dependabot_metadata(content) == content - - def test_postprocess_text_strips_dependabot_footer(self) -> None: - content = "- Update setuptools\n\n---\n\nupdated-dependencies:\n- dependency-name: setuptools\n dependency-version: 83.0.0\n...\n\n- Next entry\n" - - result = postprocess_text(content) - - assert "updated-dependencies:" not in result - assert result == "- Update setuptools\n- Next entry\n" - - def test_postprocess_text_preserves_dependabot_markers_inside_yaml_fence(self) -> None: - fenced_example = "```yaml\n---\nupdated-dependencies:\n- dependency-name: setuptools\n...\n```" - - result = postprocess_text(f"Example metadata:\n\n{fenced_example}\n") - - assert fenced_example in result - - -class TestListContinuationIndent: - def test_normalizes_over_indented_top_level_continuation(self) -> None: - lines = [ - "- Replace BigRational-only gauss_solve", - "", - " Bareiss fraction-free forward elimination.", - ] - - result = _normalize_list_continuation_indent(lines[2], lines, 2) - - assert result == " Bareiss fraction-free forward elimination." - - def test_preserves_nested_list_continuation_indent(self) -> None: - lines = [ - "- Parent item", - "", - " - Nested item", - " continued nested prose", - ] - - result = _normalize_list_continuation_indent(lines[3], lines, 3) - - assert result == " continued nested prose" - - def test_full_postprocess_normalizes_git_cliff_continuation(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Performance\n\n" - "- Integer-only Bareiss determinant via BigInt\n" - " [`d422b25`](https://github.com/acgetchell/la-stack/commit/d422b251ca86a914522f80285964d4513bca1817)\n\n" - " det_exact: 16x faster\n" - ) - - result = postprocess_text(content) - - assert "\n det_exact: 16x faster\n" in result - assert "\n det_exact: 16x faster\n" not in result - - -class TestRumdlCompatibility: - def test_removes_blank_between_peer_list_items(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Added\n\n" - "- First item\n" - " [`1111111`](https://github.com/acgetchell/la-stack/commit/1111111111111111111111111111111111111111)\n\n" - "- Second item\n" - ) - - result = postprocess_text(content) - - assert "\n\n- Second item\n" not in result - assert "\n- Second item\n" in result - - def test_removes_blank_after_nested_body_before_top_level_peer(self) -> None: - content = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- First item\n\n - Body point\n - Body point\n\n- Second item\n" - - result = postprocess_text(content) - - assert "\n\n- Second item\n" not in result - assert "\n- Second item\n" in result - - def test_removes_blank_after_mixed_body_before_top_level_peer(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Changed\n\n" - "- First item\n\n" - " Body paragraph.\n\n" - " - Body point\n" - " - Body point\n\n" - " More body prose.\n\n" - "- Second item\n" - ) - - result = postprocess_text(content) - - assert "\n\n- Second item\n" not in result - assert "\n- Second item\n" in result - - def test_preserves_blank_before_list_item_body(self) -> None: - content = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- First item\n\n Body paragraph.\n" - - result = postprocess_text(content) - - assert "- First item\n\n Body paragraph.\n" in result - - def test_keeps_body_item_tight_after_plain_peer(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Added\n\n" - "- Simple item [`1111111`](https://github.com/acgetchell/la-stack/commit/1111111111111111111111111111111111111111)\n" - "- Item with body [`2222222`](https://github.com/acgetchell/la-stack/commit/2222222222222222222222222222222222222222)\n\n" - " Body paragraph.\n" - ) - - result = postprocess_text(content) - - assert "\n- Simple item" in result - assert "\n- Item with body" in result - assert "\n\n- Item with body" not in result - - def test_normalizes_git_cliff_escaped_email_autolink(self) -> None: - line = " Co-Authored-By: Oz <oz-agent@warp.dev>" - - result = _normalize_email_autolinks(line) - - assert result == " Co-Authored-By: Oz <oz-agent@warp.dev>" - - -class TestReflowLine: - """Unit tests for the _reflow_line helper.""" - - def test_short_line_unchanged(self) -> None: - line = "- Short line `abc1234`" - assert _reflow_line(line, max_width=160) == line - - def test_wraps_plain_text(self) -> None: - line = " " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - for part in result.split("\n"): - assert len(part) <= 80 - - def test_preserves_markdown_link(self) -> None: - link = "[#235](https://github.com/acgetchell/delaunay/pull/235)" - line = f"- Description text here {link}" - result = _reflow_line(line, max_width=40) - # The link must appear intact in one of the output lines. - assert any(link in part for part in result.split("\n")) - - def test_preserves_code_span(self) -> None: - span = "`orientation_from_matrix()`" - line = f" Use {span} for exact sign classification on finite inputs and more text padding" - result = _reflow_line(line, max_width=60) - assert any(span in part for part in result.split("\n")) - - def test_list_item_continuation_indent(self) -> None: - line = "- " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - parts = result.split("\n") - assert parts[0].startswith("- ") - for cont in parts[1:]: - assert cont.startswith(" ") - - def test_star_list_item(self) -> None: - line = "* " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - parts = result.split("\n") - assert parts[0].startswith("* ") - for cont in parts[1:]: - assert cont.startswith(" ") - - def test_indented_body_text(self) -> None: - line = " " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - parts = result.split("\n") - for part in parts: - assert part.startswith(" ") - - def test_single_long_token_kept(self) -> None: - url = "https://github.com/acgetchell/delaunay/commit/" + "a" * 40 - line = f"- See [{url}]({url})" - result = _reflow_line(line, max_width=80) - # Cannot break inside the link; line may exceed max_width. - assert url in result - - def test_commit_link_with_backticks(self) -> None: - link = "[`a62437f`](https://github.com/acgetchell/delaunay/commit/a62437f25c27259f145d3c193ce149ee14b421c7)" - pr1 = "[#235](https://github.com/acgetchell/delaunay/pull/235)" - pr2 = "[#236](https://github.com/acgetchell/delaunay/pull/236)" - line = f"- Use exact arithmetic for orientation predicates {pr1} {pr2} {link}" - result = _reflow_line(line, max_width=160) - parts = result.split("\n") - # Every continuation line should be indented. - for cont in parts[1:]: - assert cont.startswith(" ") - # All links must be intact. - assert link in result - - def test_preserves_link_with_balanced_destination_parentheses(self) -> None: - link = "[API](https://example.com/search(function(arg(nested))))" - line = f"- Read the detailed publication API notes before continuing with the release process {link}" - - result = _reflow_line(line, max_width=60) - - assert link in result - - def test_preserves_multi_backtick_code_span(self) -> None: - span = "``call(`inner`, value)``" - line = f"- Use {span} when documenting the generated command and all of its arguments" - - result = _reflow_line(line, max_width=45) - - assert span in result - - -# --------------------------------------------------------------------------- -# Summary-section helpers -# --------------------------------------------------------------------------- - -_OWNER_REPO = "acgetchell/delaunay" -_PR_URL = f"https://github.com/{_OWNER_REPO}/pull" -_COMMIT_URL = f"https://github.com/{_OWNER_REPO}/commit" - - -def _pr(n: int) -> str: - """ - Return a Markdown-formatted pull request link for a given pull request number. - - Parameters: - n (int): Pull request number. - - Returns: - str: Markdown link in the form "[#<n>](<PR_URL>/<n>)". - """ - return f"[#{n}]({_PR_URL}/{n})" - - -def _commit(short: str = "abc1234", full: str = "abc1234deadbeef0123456789") -> str: - """ - Format a markdown link that references a commit using a short hash as link text and the full hash in the URL. - - Parameters: - short (str): Short commit identifier used as the link text (rendered in backticks). - full (str): Full commit hash used to construct the target URL. - - Returns: - commit_link (str): Markdown link of the form [`<short>`](<commit_url>/<full>). - """ - return f"[`{short}`]({_COMMIT_URL}/{full})" - - -def _merged_pr_summary_block(text: str) -> str: - """Return the injected merged-PR summary block.""" - start = text.index("### Merged Pull Requests") - end = text.find("\n### ", start + len("### Merged Pull Requests")) - return text[start:] if end == -1 else text[start:end] - - -class TestCompactEntry: - def test_strips_commit_hash_link(self) -> None: - line = f"- Some feature {_commit()}" - assert _compact_entry(line) == "- Some feature" - - def test_strips_breaking_prefix(self) -> None: - line = f"- [**breaking**] Some change {_commit()}" - assert _compact_entry(line, strip_breaking=True) == "- Some change" - - def test_preserves_pr_links(self) -> None: - line = f"- Feature {_pr(42)} {_commit()}" - assert _compact_entry(line) == f"- Feature {_pr(42)}" - - def test_keeps_breaking_when_not_stripped(self) -> None: - line = f"- [**breaking**] Change {_commit()}" - assert _compact_entry(line) == "- [**breaking**] Change" - - -class TestMaxPrNumber: - def test_single_pr(self) -> None: - assert _max_pr_number(f"- Feature {_pr(42)}") == 42 - - def test_multiple_prs(self) -> None: - assert _max_pr_number(f"- Feature {_pr(10)} {_pr(99)}") == 99 - - def test_no_prs(self) -> None: - assert _max_pr_number("- Plain entry") == 0 - - -class TestSummarySections: - @staticmethod - def _changelog(entries: str) -> str: - """ - Create a sample changelog file containing a header, a 1.0.0 release section dated - 2026-01-01, and an "Added" subsection populated with the provided entries. - - Parameters: - entries (str): Markdown content to place under the "Added" subsection (should include any list markers or paragraphs). - - Returns: - str: The full changelog content as a string. - """ - return f"# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n{entries}\n" - - def test_injects_pr_summary(self) -> None: - content = self._changelog(f"- Feature A {_pr(10)} {_commit()}\n- Plain commit {_commit('def5678', 'def5678deadbeef')}") - result = _inject_summary_sections(content) - summary_block = _merged_pr_summary_block(result) - assert "### Merged Pull Requests" in summary_block - # PR entry in summary (without commit hash). - assert f"- Feature A {_pr(10)}" in summary_block - assert "- Plain commit" not in summary_block - # Plain entry only appears once (in Added, not in summary). - plain_lines = [ln for ln in result.split("\n") if ln.startswith("- Plain commit")] - assert len(plain_lines) == 1 - - def test_injects_breaking_summary(self) -> None: - content = self._changelog(f"- [**breaking**] Big change {_pr(5)} {_commit()}") - result = _inject_summary_sections(content) - assert "### โš ๏ธ Breaking Changes" in result - assert "### Merged Pull Requests" in result - # Breaking section appears before Merged PRs. - assert result.index("### โš ๏ธ Breaking Changes") < result.index("### Merged Pull Requests") - - def test_injects_breaking_summary_from_marker_variants(self) -> None: - content = self._changelog(f"- **BREAKING** Big change {_pr(5)} {_commit()}") - result = _inject_summary_sections(content) - assert "### โš ๏ธ Breaking Changes" in result - assert f"- Big change {_pr(5)}" in result - - def test_injects_summary_from_star_bullets(self) -> None: - content = self._changelog(f"* [**breaking**] Star change {_pr(7)} {_commit()}") - result = _inject_summary_sections(content) - - assert "### โš ๏ธ Breaking Changes" in result - assert "### Merged Pull Requests" in result - assert f"* Star change {_pr(7)}" in result - - def test_pr_sorted_descending(self) -> None: - """ - Verifies that PRs in the injected "Merged Pull Requests" summary are sorted in descending order by PR number. - - Constructs a changelog with three entries containing PR links and confirms the summary lists them in order: highest PR number first. - """ - content = self._changelog( - f"- First {_pr(5)} {_commit('aaa1111', 'aaa1111deadbeef')}\n" - f"- Second {_pr(20)} {_commit('bbb2222', 'bbb2222deadbeef')}\n" - f"- Third {_pr(10)} {_commit('ccc3333', 'ccc3333deadbeef')}" - ) - result = _inject_summary_sections(content) - lines = result.split("\n") - pr_idx = next(i for i, ln in enumerate(lines) if "### Merged Pull Requests" in ln) - pr_lines = [ln for ln in lines[pr_idx + 1 :] if ln.startswith("- ")][:3] - assert "#20" in pr_lines[0] - assert "#10" in pr_lines[1] - assert "#5" in pr_lines[2] - - def test_no_summary_without_prs(self) -> None: - content = self._changelog(f"- Plain commit {_commit()}") - result = _inject_summary_sections(content) - assert "### Merged Pull Requests" not in result - - def test_idempotent(self) -> None: - content = self._changelog(f"- Feature {_pr(10)} {_commit()}") - first = _inject_summary_sections(content) - second = _inject_summary_sections(first) - assert first == second - - def test_idempotent_breaking_only(self) -> None: - """Breaking-only sections (no PR links) must not be double-injected.""" - content = self._changelog(f"- [**breaking**] Remove old API {_commit()}") - first = _inject_summary_sections(content) - assert "### โš ๏ธ Breaking Changes" in first - # "Merged Pull Requests" should NOT appear (no PR link). - assert "### Merged Pull Requests" not in first - second = _inject_summary_sections(first) - assert first == second - - def test_ignores_indented_sub_items(self) -> None: - content = self._changelog(f"- Feature {_commit()}\n - Sub-item {_pr(99)}") - result = _inject_summary_sections(content) - assert "### Merged Pull Requests" not in result - - def test_multiple_pr_links_preserved(self) -> None: - content = self._changelog(f"- Feature {_pr(10)} {_pr(20)} {_commit()}") - result = _inject_summary_sections(content) - summary_block = _merged_pr_summary_block(result) - assert f"- Feature {_pr(10)} {_pr(20)}" in summary_block - - def test_duplicate_summary_entries_are_collapsed(self) -> None: - entry = f"- Feature A {_pr(10)} {_commit()}" - content = self._changelog(f"{entry}\n{entry}") - result = _inject_summary_sections(content) - summary_block = _merged_pr_summary_block(result) - assert summary_block.count(f"- Feature A {_pr(10)}") == 1 - - @pytest.mark.parametrize( - ("message", "expected_summary"), - [ - ( - "perf!: tune kernels\n\nKeep arithmetic unchanged.\n\nBREAKING CHANGE: la-stack now requires Rust 1.98.1.", - "- la-stack now requires Rust 1.98.1.", - ), - ( - "fix: change result storage\n\nBREAKING CHANGE: Return `Vector<T>`.\nUse `into_array()` for raw storage.\n\nKeep explicit conversions.", - "- Return `Vector<T>`.\n Use `into_array()` for raw storage.\n\n Keep explicit conversions.", - ), - ("refactor!: remove legacy API", "- Remove legacy API"), - ( - "refactor!: change generic API\n\nBREAKING CHANGE: Replace <Old> with ``Vector<T>`` and `Result<T, E>`.", - "- Replace <Old> with ``Vector<T>`` and `Result<T, E>`.", - ), - ( - "refactor!: change generic API\n\nBREAKING CHANGE: Use this signature:\n\n```rust\nfn solve<T>() -> Result<T, Error>;\n```", - "- Use this signature:\n\n ```rust\n fn solve<T>() -> Result<T, Error>;\n ```", - ), - ( - "chore(deps): bump toolkit\n\nBREAKING CHANGE: Require a newer runtime.", - "- Require a newer runtime.", - ), - ], - ids=["msrv-footer", "multiline-footer", "bang-only", "code-spans", "fenced-code", "dependency-footer"], - ) - def test_template_preserves_breaking_descriptions(self, message: str, expected_summary: str) -> None: - """Render actual conventional footers without mutating Git history.""" - repo_root = Path(__file__).resolve().parents[2] - raw = run_safe_command( - "git-cliff", - [ - "--offline", - "--config", - str(repo_root / "cliff.toml"), - "--strip", - "footer", - "--with-commit", - message, - "--with-commit", - "fix: retain linked entry (#987)", - "HEAD..HEAD", - ], - cwd=repo_root, - ).stdout - - result = postprocess_text(raw) - - summary = result.split("### โš ๏ธ Breaking Changes\n\n", 1)[1].split("\n### ", 1)[0].strip() - assert summary == expected_summary - assert result.index("### โš ๏ธ Breaking Changes") < result.index("### Merged Pull Requests") - assert "- Retain linked entry [#987](https://github.com/acgetchell/la-stack/pull/987)" in _merged_pr_summary_block(result) - assert postprocess_text(result) == result - - -class TestListMarkerNormalization: - """MD004: consistent ``-`` list markers.""" - - def test_star_to_dash_at_column_zero(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("* item one\n* item two\n", encoding="utf-8") - postprocess(f) - result = f.read_text(encoding="utf-8") - assert "* item" not in result - assert "- item one" in result - assert "- item two" in result - - def test_star_to_dash_indented(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("- parent item\n * sub-item\n", encoding="utf-8") - postprocess(f) - assert " - sub-item" in f.read_text(encoding="utf-8") - - def test_star_in_bold_not_changed(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("Some **bold** text\n", encoding="utf-8") - postprocess(f) - assert "**bold**" in f.read_text(encoding="utf-8") - - def test_star_inside_code_block_not_changed(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("```text\n* keep me\n```\n", encoding="utf-8") - postprocess(f) - assert "* keep me" in f.read_text(encoding="utf-8") - - -class TestBlankLineBeforeList: - """MD032: blank lines around lists.""" - - def test_inserts_blank_before_list_after_prose(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("Some prose.\n- list item\n", encoding="utf-8") - postprocess(f) - assert f.read_text(encoding="utf-8") == "Some prose.\n\n- list item\n" - - def test_no_double_blank(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("Some prose.\n\n- list item\n", encoding="utf-8") - postprocess(f) - assert f.read_text(encoding="utf-8") == "Some prose.\n\n- list item\n" - - def test_no_blank_between_consecutive_items(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("- one\n- two\n", encoding="utf-8") - postprocess(f) - assert f.read_text(encoding="utf-8") == "- one\n- two\n" - - def test_no_blank_after_heading(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("### Added\n- item\n", encoding="utf-8") - postprocess(f) - # Heading directly followed by list is fine per MD032. - assert "\n\n- item" not in f.read_text(encoding="utf-8") - - -class TestIndentedHeadingNormalization: - """MD023: commit-body headings are rendered as prose, not nested headings.""" - - def test_indented_atx_heading_becomes_bold_prose(self) -> None: - assert _normalize_indented_heading(" ## Correctness Fixes") == " **Correctness Fixes**" - - def test_indented_atx_closing_sequence_becomes_bold_prose(self) -> None: - assert _normalize_indented_heading(" ### API Design ###") == " **API Design**" - - def test_column_zero_changelog_heading_is_preserved(self) -> None: - assert _normalize_indented_heading("### Added") == "### Added" - - def test_column_zero_entry_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("## Duplicate Vertex Handling") == "#### Duplicate Vertex Handling" - - def test_column_zero_category_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("### Fixed") == "### Fixed" - - def test_archives_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("## Archives") == "## Archives" - - def test_bracketed_entry_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("## [Notes]") == "#### [Notes]" - - def test_bracketed_version_like_entry_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("## [1.2.3 Notes]") == "#### [1.2.3 Notes]" - - def test_release_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("## [v1.2.3] - 2026-05-22") == "## [v1.2.3] - 2026-05-22" - - def test_prerelease_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("## [1.2.3-rc.1+build.7]") == "## [1.2.3-rc.1+build.7]" - - def test_contextual_category_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("### Fixed: Add rollback") == "#### Fixed: Add rollback" - - def test_normalized_heading_is_idempotent(self) -> None: - assert _normalize_indented_heading(" **Title**") == " **Title**" - - once = _normalize_indented_heading(" ## Correctness Fixes") - assert _normalize_indented_heading(once) == once - - def test_full_pipeline_normalizes_commit_body_headings(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Performance\n\n" - "- perf: improve Hilbert curve correctness\n\n" - " ## Correctness Fixes\n\n" - " - Add debug_assert guards\n\n" - " ## API Design\n\n" - " - Add HilbertError enum\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert " ## Correctness Fixes" not in result - assert " ## API Design" not in result - assert " **Correctness Fixes**" in result - assert " **API Design**" in result - assert "### Performance" in result - - def test_full_pipeline_normalizes_unindented_commit_body_headings(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Fixed\n\n" - "- Handle degenerate configurations [#116](https://github.com/acgetchell/causal-triangulations/pull/116)\n" - f" {_commit()}\n\n" - "## Duplicate Vertex Handling\n\n" - " - Add duplicate coordinate detection\n\n" - "### Fixed: Add rollback on cell creation failure\n\n" - " Add rollback mechanisms when cell creation fails.\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "\n## Duplicate Vertex Handling" not in result - assert "\n### Fixed: Add rollback on cell creation failure" not in result - assert "#### Duplicate Vertex Handling" in result - assert "#### Fixed: Add rollback on cell creation failure" in result - assert "### Fixed" in result - - def test_existing_archive_normalization_preserves_fenced_headings(self) -> None: - text = ( - "# Changelog - 0.5.x\n\n" - "## [0.5.3] - 2025-10-31\n\n" - "### Fixed\n\n" - "```markdown\n" - "## Example Heading\n" - "### Fixed: Example\n" - "```\n\n" - "## Duplicate Vertex Handling\n" - ) - - result = normalize_entry_headings_text(text) - - assert "```markdown\n## Example Heading\n### Fixed: Example\n```" in result - assert "#### Duplicate Vertex Handling" in result - - def test_existing_archive_normalization_preserves_tilde_fenced_headings(self) -> None: - text = "# Changelog - 0.5.x\n\n~~~markdown\n## Example Heading\n~~~\n\n## Duplicate Vertex Handling\n" - - result = normalize_entry_headings_text(text) - - assert "~~~markdown\n## Example Heading\n~~~" in result - assert "#### Duplicate Vertex Handling" in result - - -class TestSquashHeadingNormalization: - """GitHub squash-body pseudo-commit headings are rendered as prose.""" - - def test_plain_summary_removes_links_and_conventional_prefix(self) -> None: - line = f"- fix: Improve benchmark output {_pr(42)} {_commit()}" - assert _plain_summary(line) == "improve benchmark output" - - def test_plain_summary_removes_breaking_marker(self) -> None: - line = f"- [**breaking**] feat!: Remove old API {_pr(42)} {_commit()}" - assert _plain_summary(line) == "remove old api" - - def test_squash_heading_parts_maps_kind_to_changelog_label(self) -> None: - assert _squash_heading_parts(" - perf(core): speed up predicates") == ( - " ", - "Performance", - "Speed up predicates", - ) - - def test_squash_heading_parts_ignores_commit_entries(self) -> None: - assert _squash_heading_parts(f"- fix: actual commit {_commit()}") is None - - def test_conventional_squash_heading_becomes_bold_prose(self) -> None: - assert _normalize_squash_heading("- fix: close the 4D retry collapse") == "**Fixed: Close the 4D retry collapse**" - - def test_nested_squash_heading_is_indented(self) -> None: - assert _normalize_squash_heading("- Changed: harden flip diagnostics", nested=True) == " **Changed: Harden flip diagnostics**" - - def test_commit_entry_is_preserved(self) -> None: - line = f"- fix: actual commit {_commit()}" - assert _normalize_squash_heading(line) == line - - def test_duplicate_squash_heading_matches_parent_summary(self) -> None: - parent = _plain_summary("- Instrument large-scale 4D debugging") - assert _is_duplicate_squash_heading("- feat: instrument large-scale 4D debugging", parent) - - def test_duplicate_squash_heading_rejects_distinct_heading(self) -> None: - parent = _plain_summary("- Instrument large-scale 4D debugging") - assert not _is_duplicate_squash_heading("- fix: close the 4D retry collapse", parent) - - def test_isolated_body_heading_requires_blank_neighbors(self) -> None: - lines = ["- Parent entry", "", "- fix: child heading", "", " - detail"] - assert _is_isolated_body_heading(lines, 2) - assert not _is_isolated_body_heading(lines, 4) - - def test_full_pipeline_drops_duplicate_squash_heading(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Added\n\n" - f"- Instrument large-scale 4D debugging {_commit('3af976e', '3af976ec2f7c33d49803b24ab8f1a7da598fea0b')}\n\n" - "* feat: instrument large-scale 4D debugging\n\n" - " - Thread cavity-touched cells through insertion.\n\n" - "* fix: close the 4D bulk repair retry collapse\n\n" - " - Raise the D>=4 per-insertion repair budget.\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "feat: instrument large-scale 4D debugging" not in result - assert "**Fixed: Close the 4D bulk repair retry collapse**" in result - assert " - Thread cavity-touched cells through insertion." in result - - def test_full_pipeline_resets_parent_summary_at_version_heading(self, tmp_path: Path) -> None: - """Version headings reset duplicate-squash tracking between releases.""" - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Added\n\n" - f"- Repeatable summary {_commit()}\n\n" - "## [0.9.0]\n\n" - "- fixed: repeatable summary\n\n" - " - Preserve this historical squash-body heading.\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "**Fixed: Repeatable summary**" in result - assert " - Preserve this historical squash-body heading." in result - - def test_full_pipeline_preserves_non_isolated_conventional_bullets(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Documentation\n\n" - f"- Update workflow docs {_commit()}\n\n" - " - Added: `just help-workflows` references throughout\n" - " - Expanded: Testing commands\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert " - Added: `just help-workflows` references throughout" in result - assert "**Added: `just help-workflows`" not in result - - def test_full_pipeline_deindents_children_after_squash_heading(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Added\n\n" - f"- Identity-based SoS perturbation {_pr(272)} {_commit('a125d98', 'a125d98deadbeef')}\n\n" - f" - feat: Canonical vertex ordering details {_pr(266)}\n\n" - " - Add canonical_points module with sorted_cell_points helpers\n" - ) - result = postprocess_text(content) - assert f" **Added: Canonical vertex ordering details {_pr(266)}**" in result - assert "\n - Add canonical_points module with sorted_cell_points helpers\n" in result - assert "\n - Add canonical_points module" not in result - - -class TestCodeBlockLanguage: - def test_process_code_fence_opens_and_tags_bare_fence(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence("```", result, active_fence=None, next_line="let x = 1;") - - assert handled - assert active_fence == _CodeFence(delimiter="`", length=3) - assert result == ["```text"] - - def test_process_code_fence_closes_existing_block(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence( - "```", - result, - active_fence=_CodeFence(delimiter="`", length=3), - next_line=None, - ) - - assert handled - assert active_fence is None - assert result == ["```"] - - def test_process_code_fence_adds_blank_after_closing_fence(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence( - "```", - result, - active_fence=_CodeFence(delimiter="`", length=3), - next_line="following prose", - ) - - assert handled - assert active_fence is None - assert result == ["```", ""] - - def test_process_code_fence_ignores_regular_line(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence("regular text", result, active_fence=None, next_line=None) - - assert not handled - assert active_fence is None - assert result == [] - - def test_tilde_fence_is_supported_and_tagged(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence("~~~~", result, active_fence=None, next_line="code") - - assert handled - assert active_fence == _CodeFence(delimiter="~", length=4) - assert result == ["~~~~text"] - - def test_shorter_or_different_delimiter_does_not_close_fence(self) -> None: - active = _CodeFence(delimiter="`", length=4) - - handled_short, still_active = _process_code_fence("```", [], active_fence=active, next_line=None) - handled_tilde, still_active = _process_code_fence("~~~~", [], active_fence=still_active, next_line=None) - handled_close, closed = _process_code_fence("`````", [], active_fence=still_active, next_line=None) - - assert not handled_short - assert not handled_tilde - assert still_active == active - assert handled_close - assert closed is None - - def test_adds_language_to_bare_fence(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text(" ```\n let x = 1;\n ```\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "```text" in result - - def test_preserves_existing_language(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("```rust\nlet x = 1;\n```\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "```rust" in result - assert "```text" not in result - - def test_no_reflow_inside_code_block(self, tmp_path: Path) -> None: - long_code = " let very_long = " + "a" * 200 + ";" - f = tmp_path / "CHANGELOG.md" - f.write_text(f"```rust\n{long_code}\n```\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert long_code in result - - def test_no_reflow_or_heading_rewrite_inside_tilde_block(self, tmp_path: Path) -> None: - long_code = "## " + "code " * 50 - f = tmp_path / "CHANGELOG.md" - f.write_text(f"~~~markdown\n{long_code.rstrip()}\n~~~\n## Outside\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert long_code.rstrip() in result - assert "~~~markdown" in result - assert "#### Outside" in result - - def test_adds_blank_after_code_block_before_prose(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("```text\ncode\n```\nfollowing prose\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "```text\ncode\n```\n\nfollowing prose\n" - - -class TestIntegration: - def test_full_pipeline_is_idempotent(self) -> None: - content = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n\n- fixed: preserve the generated body\n\n - Historical detail.\n" - - once = postprocess_text(content) - - assert "\n - Historical detail.\n" in once - assert postprocess_text(once) == once - - def test_full_changelog_reflow(self, tmp_path: Path) -> None: - """Simulate a realistic changelog snippet with long lines.""" - long_entry = ( - "- Use exact arithmetic [#235](https://github.com/acgetchell/delaunay/pull/235) " - "[#236](https://github.com/acgetchell/delaunay/pull/236) " - "[`a62437f`](https://github.com/acgetchell/delaunay/commit/a62437f25c27259f145d3c193ce149ee14b421c7)" - ) - long_body = " " + "word " * 40 - content = f"# Changelog\n\n## [0.7.2]\n\n### Added\n\n{long_entry}\n\n{long_body.rstrip()}\n\n" - - f = tmp_path / "CHANGELOG.md" - f.write_text(content, encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - for line in result.split("\n"): - if line.strip(): - assert len(line) <= 160 or "](" in line or "http" in line, f"Line too long ({len(line)}): {line[:80]}..." - - def test_summary_sections_in_full_pipeline(self, tmp_path: Path) -> None: - """Summary sections are injected and survive reflow.""" - entry = f"- Feature {_pr(42)} {_commit()}" - content = f"# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n{entry}\n" - f = tmp_path / "CHANGELOG.md" - f.write_text(content, encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "### Merged Pull Requests" in result - assert "### Added" in result - # Summary appears before categorised sections. - assert result.index("### Merged Pull Requests") < result.index("### Added") diff --git a/scripts/tests/test_tag_release.py b/scripts/tests/test_tag_release.py deleted file mode 100644 index b58122d..0000000 --- a/scripts/tests/test_tag_release.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Tests for tag_release.py โ€” annotated tag creation with size-limit handling.""" - -import subprocess -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -import tag_release -from tag_release import ( - _GITHUB_TAG_ANNOTATION_LIMIT, - _get_repo_url, - _github_anchor, - extract_changelog_section, - find_changelog, - parse_version, - validate_semver, -) - -if TYPE_CHECKING: - from pathlib import Path - -# --------------------------------------------------------------------------- -# SemVer validation -# --------------------------------------------------------------------------- - - -class TestValidateSemver: - @pytest.mark.parametrize( - "version", - [ - "v0.1.0", - "v1.0.0", - "v12.34.56", - "v1.2.3-rc.1", - "v1.2.3-alpha", - "v1.2.3+build.42", - "v1.2.3-beta.1+build.123", - "v1.0.0-1a", # digit-prefixed alphanumeric prerelease - "v1.0.0-0a", # leading zero OK when not purely numeric - "v1.0.0-1a.2b", # dot-separated digit-prefixed IDs - ], - ) - def test_valid_versions(self, version: str) -> None: - validate_semver(version) # should not raise - - @pytest.mark.parametrize( - "version", - [ - "0.1.0", # missing v prefix - "v1", # incomplete - "v1.2", # missing patch - "v01.2.3", # leading zero - "v1.02.3", # leading zero - "v1.2.03", # leading zero - "v1.0.0-01", # leading zero in purely numeric prerelease - "vfoo", # garbage - "", # empty - ], - ) - def test_invalid_versions(self, version: str) -> None: - with pytest.raises(ValueError, match="SemVer format"): - validate_semver(version) - - -class TestParseVersion: - def test_strips_v_prefix(self) -> None: - assert parse_version("v1.2.3") == "1.2.3" - - def test_no_prefix(self) -> None: - assert parse_version("1.2.3") == "1.2.3" - - -# --------------------------------------------------------------------------- -# Changelog helpers -# --------------------------------------------------------------------------- - - -_SAMPLE_CHANGELOG = """\ -# Changelog - -## [0.2.0] - 2025-03-01 - -### Added - -- Exact determinant sign via `det_sign_exact()` - -### Changed - -- Bump version to 0.2.0 - -## [0.1.3] - 2025-02-15 - -### Fixed - -- Minor doc typo -""" - - -class TestFindChangelog: - def test_finds_in_current_dir(self, tmp_path: Path) -> None: - (tmp_path / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8") - result = find_changelog(tmp_path) - assert result.name == "CHANGELOG.md" - - def test_finds_in_parent_dir(self, tmp_path: Path) -> None: - (tmp_path / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8") - child = tmp_path / "scripts" - child.mkdir() - result = find_changelog(child) - assert result.name == "CHANGELOG.md" - - def test_raises_when_missing(self, tmp_path: Path) -> None: - with pytest.raises(FileNotFoundError, match=r"CHANGELOG\.md not found"): - find_changelog(tmp_path) - - -class TestExtractChangelogSection: - def test_extracts_section(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - section, source = extract_changelog_section(changelog, "0.2.0") - assert "det_sign_exact" in section - assert "Bump version" in section - assert source == changelog - # Should not include content from 0.1.3 - assert "Minor doc typo" not in section - - def test_extracts_older_section(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - section, source = extract_changelog_section(changelog, "0.1.3") - assert "Minor doc typo" in section - assert "det_sign_exact" not in section - assert source == changelog - - def test_raises_for_missing_version(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - with pytest.raises(LookupError, match="No changelog section found"): - extract_changelog_section(changelog, "9.9.9") - - def test_raises_for_empty_section(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n\n## [1.0.0] - 2025-01-01\n\n## [0.9.0] - 2024-12-01\n", encoding="utf-8") - - with pytest.raises(LookupError, match="empty"): - extract_changelog_section(changelog, "1.0.0") - - -# --------------------------------------------------------------------------- -# GitHub anchor generation -# --------------------------------------------------------------------------- - - -class TestGitHubAnchor: - """Verify _github_anchor matches github-slugger output.""" - - def test_bracketed_heading(self, tmp_path: Path) -> None: - """Heading ``## [1.0.0] - 2025-01-01`` should strip brackets and dots.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n\n## [1.0.0] - 2025-01-01\n\n- Item\n", - encoding="utf-8", - ) - assert _github_anchor(changelog, "1.0.0") == "100---2025-01-01" - - def test_plain_v_heading(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n\n## v0.2.0\n\n- Item\n", - encoding="utf-8", - ) - assert _github_anchor(changelog, "0.2.0") == "v020" - - def test_fallback_when_not_found(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n", encoding="utf-8") - assert _github_anchor(changelog, "9.9.9") == "v999" - - def test_does_not_match_prerelease_heading(self, tmp_path: Path) -> None: - """Looking for 1.0.0 must not match ## [1.0.0-rc.1].""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n\n## [1.0.0-rc.1] - 2025-01-01\n\n- Item\n", - encoding="utf-8", - ) - # Should fall back since no exact 1.0.0 heading exists - assert _github_anchor(changelog, "1.0.0") == "v100" - - -# --------------------------------------------------------------------------- -# Tag size limit handling -# --------------------------------------------------------------------------- - - -class TestTagSizeLimit: - def test_small_section_uses_full_content(self, tmp_path: Path) -> None: - """A normal-sized changelog section should be used as the tag message.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - section, _source = extract_changelog_section(changelog, "0.2.0") - assert len(section.encode("utf-8")) < _GITHUB_TAG_ANNOTATION_LIMIT - - def test_oversized_section_detected(self, tmp_path: Path) -> None: - """Synthetic oversized changelog should exceed the limit.""" - # Build content > 125KB - lines = [f"- Item number {i}" for i in range(20_000)] - big_section = "\n".join(lines) - - changelog_text = f"# Changelog\n\n## [1.0.0] - 2025-01-01\n\n{big_section}\n\n## [0.9.0] - 2024-12-01\n\n- Old item\n" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(changelog_text, encoding="utf-8") - - section, _source = extract_changelog_section(changelog, "1.0.0") - assert len(section.encode("utf-8")) > _GITHUB_TAG_ANNOTATION_LIMIT - - -# --------------------------------------------------------------------------- -# create_tag workflow (mocked git) -# --------------------------------------------------------------------------- - - -class TestCreateTag: - @pytest.fixture(autouse=True) - def _matching_package_version(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(tag_release, "_package_version", lambda _changelog: "1.0.0") - - def test_main_preserves_captured_git_diagnostics( - self, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - ) -> None: - def fail_create_tag(_version: str, *, force: bool = False) -> None: - del force - raise subprocess.CalledProcessError( - 128, - ["git", "tag", "v1.0.0"], - output="tag stdout", - stderr="tag rejected by hook", - ) - - monkeypatch.setattr(tag_release, "create_tag", fail_create_tag) - - assert tag_release.main(["v1.0.0"]) == 1 - captured = capsys.readouterr() - assert "git tag v1.0.0" in captured.err - assert "tag stdout" in captured.err - assert "tag rejected by hook" in captured.err - assert "Traceback" not in captured.err - - def test_next_step_sets_release_title( - self, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - with ( - patch("tag_release._tag_exists", return_value=False), - patch("tag_release.find_changelog", return_value=changelog), - patch( - "tag_release.extract_changelog_section", - return_value=("### Added\n\n- Something new", changelog), - ), - patch("tag_release.run_git_command_with_input"), - ): - tag_release.create_tag("v1.0.0") - - assert "gh release create v1.0.0 --title v1.0.0 --notes-from-tag" in capsys.readouterr().out - - @patch("tag_release.run_git_command_with_input") - @patch("tag_release._tag_exists", return_value=False) - @patch("tag_release.find_changelog") - @patch("tag_release.extract_changelog_section") - def test_creates_annotated_tag( - self, - mock_extract: MagicMock, - mock_find: MagicMock, - mock_exists: MagicMock, - mock_git_input: MagicMock, - tmp_path: Path, - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - mock_find.return_value = changelog - mock_extract.return_value = ("### Added\n\n- Something new", changelog) - - tag_release.create_tag("v1.0.0") - mock_exists.assert_called_once_with("v1.0.0") - - mock_git_input.assert_called_once() - call_args = mock_git_input.call_args - assert call_args[0][0] == ["tag", "-a", "v1.0.0", "-F", "-", "--cleanup=verbatim"] - assert "Something new" in call_args[1]["input_data"] - - @patch("tag_release.run_git_command_with_input") - @patch("tag_release._get_repo_url", return_value="https://github.com/acgetchell/la-stack") - @patch("tag_release._tag_exists", return_value=False) - @patch("tag_release.find_changelog") - def test_oversized_creates_reference_tag( - self, - mock_find: MagicMock, - mock_exists: MagicMock, - mock_url: MagicMock, - mock_git_input: MagicMock, - tmp_path: Path, - ) -> None: - """When changelog exceeds 125KB, tag message should be a short reference.""" - lines = [f"- Item number {i}" for i in range(20_000)] - big_section = "\n".join(lines) - changelog_text = f"# Changelog\n\n## [1.0.0] - 2025-01-01\n\n{big_section}\n\n## [0.9.0] - 2024-12-01\n\n- Old\n" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(changelog_text, encoding="utf-8") - mock_find.return_value = changelog - - # Patch extract to return the real oversized content - with patch("tag_release.extract_changelog_section", return_value=(big_section, changelog)): - tag_release.create_tag("v1.0.0") - - mock_exists.assert_called_once_with("v1.0.0") - mock_url.assert_called_once_with() - mock_git_input.assert_called_once() - tag_message = mock_git_input.call_args[1]["input_data"] - assert "See full changelog" in tag_message - assert "CHANGELOG.md" in tag_message - assert len(tag_message) < 1000 - - @patch("tag_release._tag_exists", return_value=True) - def test_existing_tag_without_force_exits(self, mock_exists: MagicMock) -> None: - with pytest.raises(SystemExit): - tag_release.create_tag("v1.0.0", force=False) - mock_exists.assert_called_once_with("v1.0.0") - - @patch("tag_release.run_git_command_with_input") - @patch("tag_release._tag_exists", return_value=True) - @patch("tag_release.find_changelog") - @patch("tag_release.extract_changelog_section") - def test_force_recreates_tag( - self, - mock_extract: MagicMock, - mock_find: MagicMock, - mock_exists: MagicMock, - mock_git_input: MagicMock, - tmp_path: Path, - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - mock_find.return_value = changelog - mock_extract.return_value = ("### Fixed\n\n- Bug fix", changelog) - - tag_release.create_tag("v1.0.0", force=True) - - mock_exists.assert_called_once_with("v1.0.0") - mock_git_input.assert_called_once() - assert mock_git_input.call_args[0][0] == ["tag", "-f", "-a", "v1.0.0", "-F", "-", "--cleanup=verbatim"] - - @patch("tag_release._tag_exists", return_value=True) - @patch("tag_release.find_changelog") - @patch("tag_release.extract_changelog_section", side_effect=LookupError("not found")) - @patch("tag_release.run_git_command_with_input") - def test_force_does_not_delete_tag_if_changelog_fails( - self, - mock_git_input: MagicMock, - mock_extract: MagicMock, - mock_find: MagicMock, - mock_exists: MagicMock, - tmp_path: Path, - ) -> None: - """Tag must not be deleted if changelog extraction fails.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n", encoding="utf-8") - mock_find.return_value = changelog - - with pytest.raises(LookupError): - tag_release.create_tag("v1.0.0", force=True) - - mock_exists.assert_called_once_with("v1.0.0") - mock_extract.assert_called_once() - mock_git_input.assert_not_called() - - def test_invalid_remote_does_not_replace_existing_tag( - self, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Oversized-tag fallback validates its remote before mutating a tag.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n", encoding="utf-8") - section = "x" * (_GITHUB_TAG_ANNOTATION_LIMIT + 1) - marker = "release-token" - - with ( - patch("tag_release._tag_exists", return_value=True), - patch("tag_release.find_changelog", return_value=changelog), - patch("tag_release.extract_changelog_section", return_value=(section, changelog)), - patch("tag_release.run_git_command") as mock_git, - patch("tag_release.run_git_command_with_input") as mock_git_input, - ): - mock_git.return_value.stdout = f"https://github.com/acgetchell/la-stack.git?token={marker}" - - with pytest.raises(ValueError, match="query parameters") as exc_info: - tag_release.create_tag("v1.0.0", force=True) - - mock_git_input.assert_not_called() - output = capsys.readouterr() - assert marker not in str(exc_info.value) - assert marker not in output.out - assert marker not in output.err - - def test_rejects_tag_that_differs_from_cargo_before_git(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - mock_exists = MagicMock() - monkeypatch.setattr(tag_release, "find_changelog", lambda: changelog) - monkeypatch.setattr(tag_release, "_package_version", lambda _changelog: "1.0.1") - monkeypatch.setattr(tag_release, "_tag_exists", mock_exists) - - with pytest.raises(ValueError, match="does not match Cargo package version"): - tag_release.create_tag("v1.0.0") - - mock_exists.assert_not_called() - - -class TestRepoUrl: - @pytest.mark.parametrize( - ("raw", "expected"), - [ - ("git@github.com:acgetchell/la-stack.git", "https://github.com/acgetchell/la-stack"), - ("https://github.com/acgetchell/la-stack.git", "https://github.com/acgetchell/la-stack"), - ("ssh://git@github.com/acgetchell/la-stack.git", "https://github.com/acgetchell/la-stack"), - ], - ) - @patch("tag_release.run_git_command") - def test_normalizes_github_remotes(self, mock_git: MagicMock, raw: str, expected: str) -> None: - mock_git.return_value.stdout = raw - - assert _get_repo_url() == expected - - @patch("tag_release.run_git_command") - def test_rejects_remote_urls_with_credentials(self, mock_git: MagicMock) -> None: - raw = "https://user:secret-token@github.com/acgetchell/la-stack.git" - mock_git.return_value.stdout = raw - - with pytest.raises(ValueError, match="contain credentials") as exc_info: - _get_repo_url() - - assert "secret-token" not in str(exc_info.value) - assert raw not in str(exc_info.value) - - @pytest.mark.parametrize( - "raw", - [ - "https://github.com/acgetchell/la-stack.git?token=secret-token", - "https://github.com/acgetchell/la-stack.git#secret-token", - ], - ) - @patch("tag_release.run_git_command") - def test_rejects_query_and_fragment_without_echoing_them(self, mock_git: MagicMock, raw: str) -> None: - mock_git.return_value.stdout = raw - - with pytest.raises(ValueError, match="query parameters or fragments") as exc_info: - _get_repo_url() - - assert "secret-token" not in str(exc_info.value) - assert raw not in str(exc_info.value) - - @pytest.mark.parametrize( - "raw", - [ - "http://github.com/acgetchell/la-stack.git", - "https://example.com/acgetchell/la-stack.git", - "git@example.com:acgetchell/la-stack.git", - "ssh://developer@github.com/acgetchell/la-stack.git", - "https://github.com/acgetchell/la-stack/extra.git", - "file:///tmp/la-stack.git", - "/home/example/la-stack.git", - "../la-stack.git", - "https://[malformed", - ], - ) - @patch("tag_release.run_git_command") - def test_rejects_unsupported_remotes_without_echoing_them(self, mock_git: MagicMock, raw: str) -> None: - mock_git.return_value.stdout = raw - - with pytest.raises(ValueError, match="Origin remote") as exc_info: - _get_repo_url() - - assert raw not in str(exc_info.value) diff --git a/semgrep.yaml b/semgrep.yaml index a98ed50..63328a0 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -871,6 +871,7 @@ rules: - "/tests/semgrep/docs/**/*.md" exclude: - "/docs/archive/**" + - "/docs/archives/changelog/**" pattern-regex: '\.(?:unwrap(?:_[A-Za-z0-9_]+)?|expect)\s*\(' - id: la-stack.rust.no-box-dyn-error-in-doctests diff --git a/uv.lock b/uv.lock index 4f4f4fb..757d1a7 100644 --- a/uv.lock +++ b/uv.lock @@ -461,6 +461,7 @@ source = { editable = "." } dev = [ { name = "actionlint-py" }, { name = "pytest" }, + { name = "research-repo-tools" }, { name = "ruff" }, { name = "semgrep" }, { name = "shellcheck-py" }, @@ -468,6 +469,9 @@ dev = [ { name = "ty" }, { name = "yamllint" }, ] +tooling = [ + { name = "research-repo-tools" }, +] [package.metadata] @@ -475,6 +479,7 @@ dev = [ dev = [ { name = "actionlint-py", specifier = "==1.7.12.24" }, { name = "pytest", specifier = "==9.1.1" }, + { name = "research-repo-tools", specifier = "==0.1.7" }, { name = "ruff", specifier = "==0.16.6" }, { name = "semgrep", specifier = "==1.176.1" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, @@ -482,6 +487,7 @@ dev = [ { name = "ty", specifier = "==0.0.79" }, { name = "yamllint", specifier = "==1.38.0" }, ] +tooling = [{ name = "research-repo-tools", specifier = "==0.1.7" }] [[package]] name = "markdown-it-py" @@ -915,6 +921,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "research-repo-tools" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyyaml" }, + { name = "rust-just" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/bf/ee8d41dab3fe80da99147b7caebae7eb72a4001606fef588273bd369312b/research_repo_tools-0.1.7.tar.gz", hash = "sha256:2a8aa607a6ffccf2c5e2a48565e1eceb4ab3f361ebbf2136d6fa5a197ce68a19", size = 540024, upload-time = "2026-09-26T20:01:09.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/e7/58faeb7238ea1276471670ca5e746834da07b67e201eb9ef4c8a5a24f8d3/research_repo_tools-0.1.7-py3-none-any.whl", hash = "sha256:ff60397c563d5a7a89d73ddba219e741dd3f4936bfa001ea498c4607aea05cb6", size = 232872, upload-time = "2026-09-26T20:01:07.556Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1046,6 +1066,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] +[[package]] +name = "rust-just" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/d5/794e00d644caa238e931d4991d69951401492de55aa19334fdac72322ce2/rust_just-1.58.0.tar.gz", hash = "sha256:3226a7f201bf9a7766e676d32c5d8c5ae4fe0e3642628e0a9d4b62394dd0cefe", size = 2006103, upload-time = "2026-08-04T02:59:21.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/12/66b02e33ada6fe66f77091f298de83ae8221ff0ac04472a16adea742688a/rust_just-1.58.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cf620ee15c9f12246ba628000d96f338f395d03c770dd55826ceed26881f9843", size = 2289171, upload-time = "2026-08-04T02:58:57.345Z" }, + { url = "https://files.pythonhosted.org/packages/46/2f/d223d8d3f6e686365d7e0377c7df276dbb0afa407ed5749c654920906152/rust_just-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:95029add8ceab772ce9e3542fad9543d13d142513c7898987fd7ad1317090101", size = 2129203, upload-time = "2026-08-04T02:58:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3c/4e548af5d2e23d4f5946e226f8ebf7daac1ee1803f9739d0a4193d63dfb0/rust_just-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1483226b767db4a0ea523eedd165667663115514c92a50577c9ab84aba76352", size = 2217447, upload-time = "2026-08-04T02:59:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f3/8fbbdc706a52c875a46dd4c33d8d0220b76a712ed1aff68cc47590c1eabf/rust_just-1.58.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6b7536e87b0fb4e9f0856488a296977dabaf2f3b6761bf084ec70ed6c8107255", size = 2188508, upload-time = "2026-08-04T02:59:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/f8/df/218a4b42a5bc37d575cdf7aef2cdaea1d64679aa542361ca157ab15e3e65/rust_just-1.58.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77df7f183f54a3be94e331b98cbd27f299368ad2a8b208f3ac49af76ea0954b9", size = 2390783, upload-time = "2026-08-04T02:59:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/86/05/001c035ba15b19a81039643506e62e5b82a80d4461617ec8dcac59c7dd7e/rust_just-1.58.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54521c790615033f92e2336b73f04017fdb3e8f7e57143011b2cb7b17ea74a36", size = 2504440, upload-time = "2026-08-04T02:59:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/26/90/187046be529d840eec91ac51ecfd0fb8fc911f1771e0e51d85c3b03cd248/rust_just-1.58.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83dfc3d8b4802d7e21e8eefb102df049f9b171b998617735579d717bf01d098e", size = 2462963, upload-time = "2026-08-04T02:59:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/31/f1/ddd312fe9e792bf3dde3bf7669e77a1ca069cebba864ee2a625b757207c3/rust_just-1.58.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb4e2cc32ac8f3ec540129b09b818403cbbca44d84ebda4bb9e4b045dbd48a47", size = 2381777, upload-time = "2026-08-04T02:59:07.852Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7d/98fa3f4af78ea2339109d5fc70751bae918ad8438b3205fd9f7d532e8021/rust_just-1.58.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9085e86d98d15272e00721b95d125b63f28de0adc842127271ef3c1223bf150a", size = 2238418, upload-time = "2026-08-04T02:59:09.098Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/4059516b02862be59d37f389e64a77c6252effcfeabeb932a6793bc2ed81/rust_just-1.58.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2c6a9facc5aab021d1d54cc03fc406ab49ab7cbe802f233ba99db6251bfdb3b0", size = 2219864, upload-time = "2026-08-04T02:59:10.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8d/6c79364ce1973b405d90127f552fd21a372a1322a62d03af9f4fcc2199dc/rust_just-1.58.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:73f12c197573a945b6dc17e77d7c2bd3c1b297f9ddba9b2d64f19b79d9c5568c", size = 2210179, upload-time = "2026-08-04T02:59:12.123Z" }, + { url = "https://files.pythonhosted.org/packages/69/b4/287008f012ec8fd6022da97e08abc147e195577c23ca4e919ceac3609ad9/rust_just-1.58.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d4b5d2bf0f05b87d3bfdcf698583f4f64047c6b4f1f49bc002b57d21db3ed980", size = 2352630, upload-time = "2026-08-04T02:59:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1f/cb3c22d56649417792ed6c1e7b7d0835cc3f440a2f0a8ea332a2dbc9d26e/rust_just-1.58.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:4f95cccd7f85cb55da30d362d748747e89dfd24869973310168214bc7331cbac", size = 2413427, upload-time = "2026-08-04T02:59:15.204Z" }, + { url = "https://files.pythonhosted.org/packages/50/c8/1a3880ee39ee25dc2d8f1e5f965a2bed402859ada434d19b131d0cd86beb/rust_just-1.58.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6c0cdfe4db533d37a3ac0fd790b3fa21f8e3d913cc218fcde84c40bc02019667", size = 2461925, upload-time = "2026-08-04T02:59:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/2f/56/3cc19726dec58831872bbedfac31f57e68db1c0bedbb4102714f0a45ef66/rust_just-1.58.0-py3-none-win32.whl", hash = "sha256:0e9baefc1464ab81a337f559e25a5c0c7f3d5c993d2fcd9ba1bc2585ab4bbac2", size = 2137004, upload-time = "2026-08-04T02:59:18.417Z" }, + { url = "https://files.pythonhosted.org/packages/fa/31/234039ddfd4fc10dc16679063d10848423fee6ab13aac3f5d8ce5d1d8b27/rust_just-1.58.0-py3-none-win_amd64.whl", hash = "sha256:145cea724b6d981422ad5357ac6fec53be62b8146ac67a3921c89a0db152887d", size = 2358021, upload-time = "2026-08-04T02:59:19.817Z" }, +] + [[package]] name = "semantic-version" version = "2.10.0" From cb8c471511bf4f7f5f50cdfe3d7a0296e3357f14 Mon Sep 17 00:00:00 2001 From: Adam <adam@adamgetchell.org> Date: Wed, 30 Sep 2026 17:44:24 +0000 Subject: [PATCH 2/5] fix(changelog): preserve consumer command and release contracts - Forward preview options to the shared CLI and keep generated archives under the dedicated changelog formatter and final link check. - Accept valid included dependency groups while advancing only direct dev pins, leaving the shared tooling dependency under its own upgrade policy. - Keep declared release dates valid after a later merge and include canonical changelog archives in the release staging guidance. Refs #254 --- docs/RELEASING.md | 2 +- justfile | 5 ++- pyproject.toml | 4 ++ scripts/README.md | 10 ++++- scripts/tests/test_changelog_integration.py | 42 +++++++++++++++++++- scripts/tests/test_update_python_dev_pins.py | 40 ++++++++++++++++--- scripts/update_python_dev_pins.py | 12 ++++-- 7 files changed, 101 insertions(+), 14 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6d8703c..43f8004 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -169,7 +169,7 @@ git --no-pager diff Expected release artifacts include package metadata and lockfiles, `CITATION.cff`, `CHANGELOG.md`, `README.md`, `docs/performance.md`, and generated -files under `docs/archive/` and `docs/assets/bench/`. Stage only the reviewed +files under `docs/archive/`, `docs/archives/changelog/`, and `docs/assets/bench/`. Stage only the reviewed paths that were intentionally changed; do not stage the entire `docs/` tree. Then inspect the staged diff and commit it: diff --git a/justfile b/justfile index ac7e917..e2815ce 100644 --- a/justfile +++ b/justfile @@ -378,6 +378,7 @@ changelog-check: _ensure-rumdl python-sync rumdl check --no-cache --config pyproject.toml CHANGELOG.md docs/archives/changelog/*.md # Validate generation and print the root changelog without publishing candidates. +[positional-arguments] changelog-preview *args: _ensure-git-cliff _ensure-rumdl python-sync #!/usr/bin/env bash set -euo pipefail @@ -580,7 +581,7 @@ markdown-check: _ensure-rumdl _ensure-uv files=() while IFS= read -r -d '' file; do case "$file" in - CHANGELOG.md|docs/archive/*) continue ;; + CHANGELOG.md|docs/archive/*|docs/archives/changelog/*) continue ;; esac if [ -f "$file" ]; then files+=("$file") @@ -602,7 +603,7 @@ markdown-fix: _ensure-rumdl files=() while IFS= read -r -d '' file; do case "$file" in - CHANGELOG.md|docs/archive/*) continue ;; + CHANGELOG.md|docs/archive/*|docs/archives/changelog/*) continue ;; esac if [ -f "$file" ]; then files+=("$file") diff --git a/pyproject.toml b/pyproject.toml index 8fea857..ce2e116 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,3 +169,7 @@ repository = "la-stack" formatter = "changelog-rumdl.toml" # Historical deps-dev bodies contain release-note links that the packaged policy omits. cliff-config = "cliff.toml" + +[tool.research-repo-tools.release] +# Release preparation and post-merge tagging can happen on different UTC days. +date-policy = "declared" diff --git a/scripts/README.md b/scripts/README.md index ae8c022..8816a7d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -293,6 +293,11 @@ common regressions belong to the shared package; this repository owns the configuration, recipes, and `scripts/tests/test_changelog_integration.py`. Internal shared modules are not a supported consumer API. +The consumer-owned `update-python` helper advances only exact requirements +declared directly in `dev`. Included groups retain their own upgrade policy; +the shared `tooling` pin and its lockfile change together through an intentional +dependency upgrade. + `just changelog` generates, normalizes, formats, and rotates completed minor series in one operation. It retains Unreleased and the newest minor series in `CHANGELOG.md`, with older releases in `docs/archives/changelog/MAJOR.MINOR.md`. @@ -334,8 +339,9 @@ just tag-force vX.Y.Z # replace that tag only when explicitly repairing it These recipes call `research-repo-tools changelog tag`. It searches the root and canonical archives, validates the entire history, and requires the -`v`-prefixed tag to match the Cargo package version. The dated release heading -must equal today's UTC date and any `CITATION.cff` date. Oversized annotations +`v`-prefixed tag to match the Cargo package version. The consumer's `declared` +date policy requires the dated release heading to match any `CITATION.cff` date, +so a release prepared before merge can be tagged on a later UTC day. Oversized annotations link to the full notes to respect GitHub's 125KB limit. `--force` replaces an existing local ref only after validation; it does not first delete the old tag. Tagging never pushes or publishes a release. Use the CLI's `--dry-run` to diff --git a/scripts/tests/test_changelog_integration.py b/scripts/tests/test_changelog_integration.py index a6dcef1..f8e5093 100644 --- a/scripts/tests/test_changelog_integration.py +++ b/scripts/tests/test_changelog_integration.py @@ -1,10 +1,12 @@ """Consumer integration with the pinned, published changelog CLI.""" import json +import os import re import shutil +import sys import tomllib -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from importlib.metadata import version from pathlib import Path @@ -227,6 +229,31 @@ def test_consumer_recipes_forward_cli_arguments_and_keep_metadata_separate() -> assert "update-release-version" not in result.stderr +def test_preview_recipe_executes_explicit_tag_and_date_without_publishing(consumer: Path) -> None: + """Run the imported Just adapter and installed CLI, skipping installation only.""" + commit(consumer, "feat: initial fixture") + run_git_command(["tag", "v0.4.5"], cwd=consumer) + commit(consumer, "fix: preview target date") + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.5] - 2000-01-02\n\n- Retained.\n", encoding="utf-8") + wrapper = consumer / "recipes.just" + wrapper.write_text( + f"set allow-duplicate-recipes\nimport '{(REPO_ROOT / 'justfile').as_posix()}'\n_ensure-git-cliff:\n\n_ensure-rumdl:\n\npython-sync:\n", + encoding="utf-8", + ) + before = markdown_bytes(consumer) + metadata = {name: (consumer / name).read_bytes() for name in ("Cargo.toml", "pyproject.toml")} + result = run_safe_command( + "just", + ["--justfile", str(wrapper), "--working-directory", str(consumer), "changelog-preview", "--tag", "v0.5.0", "--date", "2026-09-30"], + cwd=consumer, + env=os.environ | {"UV_NO_SYNC": "1", "UV_PROJECT_ENVIRONMENT": sys.prefix}, + ) + assert "## [0.5.0] - 2026-09-30" in result.stdout + assert markdown_bytes(consumer) == before + assert {name: (consumer / name).read_bytes() for name in metadata} == metadata + assert not (consumer / "docs/archives/changelog").exists() + + def test_final_repository_links_and_archived_notes_remain_valid(capsys: pytest.CaptureFixture[str]) -> None: """Check consumer history, including references in every completed series.""" assert cli(REPO_ROOT, "check") == 0 @@ -261,6 +288,19 @@ def test_tag_preview_checks_citation_date_and_handles_oversized_notes(consumer: assert run_git_command(["tag", "--list"], cwd=consumer).stdout == "" +def test_tag_accepts_a_declared_release_date_before_the_tagging_day(consumer: Path) -> None: + """A release prepared before merge remains taggable with matching metadata.""" + commit(consumer, "feat: prior-day release fixture") + released = (datetime.now(UTC).date() - timedelta(days=1)).isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- Prior-day release.\n", encoding="utf-8") + (consumer / "CITATION.cff").write_text(f"version: 0.4.6\ndate-released: {released}\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "tag", "v0.4.6") == 0 + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=consumer).stdout.strip() == "tag" + assert "Prior-day release" in run_git_command(["cat-file", "-p", "refs/tags/v0.4.6"], cwd=consumer).stdout + assert markdown_bytes(consumer) == before + + def test_normalization_preserves_fenced_examples_and_prerelease_notes(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: """Rust code, Markdown links, and fenced release examples remain literal.""" (consumer / "CHANGELOG.md").write_text( diff --git a/scripts/tests/test_update_python_dev_pins.py b/scripts/tests/test_update_python_dev_pins.py index 1ff027b..8a39c34 100644 --- a/scripts/tests/test_update_python_dev_pins.py +++ b/scripts/tests/test_update_python_dev_pins.py @@ -1,15 +1,13 @@ """Tests for resolver-backed Python development-tool pin updates.""" import subprocess -from typing import TYPE_CHECKING +import tomllib +from pathlib import Path import pytest import update_python_dev_pins -if TYPE_CHECKING: - from pathlib import Path - def project_text(*requirements: str) -> str: """Return a minimal project with exact development-tool pins.""" @@ -36,6 +34,33 @@ def test_parse_project_accepts_exact_simple_dev_pins() -> None: ] +def test_actual_project_keeps_included_tooling_outside_direct_dev_updates() -> None: + text = (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text(encoding="utf-8") + python_version, pins = update_python_dev_pins.parse_project(text) + groups = tomllib.loads(text)["dependency-groups"] + assert python_version == "3.14" + assert groups["tooling"] == ["research-repo-tools==0.1.7"] + assert {"include-group": "tooling"} in groups["dev"] + assert "research-repo-tools" not in {pin.name for pin in pins} + assert [f"{pin.name}=={pin.version}" for pin in pins] == [entry for entry in groups["dev"] if isinstance(entry, str)] + + +@pytest.mark.parametrize( + ("entry", "error", "diagnostic"), + [ + ('{ include-group = "missing" }', ValueError, "existing dependency group"), + ("{ include-group = 42 }", ValueError, "existing dependency group"), + ('{ include-group = "dev" }', ValueError, "other than dev"), + ('{ include-group = "tooling", extra = true }', TypeError, "strings or include-group tables"), + ("42", TypeError, "strings or include-group tables"), + ], +) +def test_parse_project_rejects_malformed_dev_entries(entry: str, error: type[Exception], diagnostic: str) -> None: + text = project_text("ruff==0.16.2").replace("dev = [", f"dev = [\n {entry},") + 'tooling = ["research-repo-tools==0.1.7"]\n' + with pytest.raises(error, match=diagnostic): + update_python_dev_pins.parse_project(text) + + def test_parse_project_rejects_non_exact_dev_requirement() -> None: with pytest.raises(ValueError, match=r"development-tool requirements must be exact simple pins: ruff>=0\.16"): update_python_dev_pins.parse_project(project_text("ruff>=0.16")) @@ -61,12 +86,17 @@ def test_parse_resolution_rejects_missing_direct_tool() -> None: update_python_dev_pins.parse_resolution("mcp==1.29.0\n", pins) +@pytest.mark.parametrize("include_tooling", [False, True]) def test_update_dev_pins_resolves_then_applies_one_exact_transaction( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + include_tooling: bool, ) -> None: pyproject = tmp_path / "pyproject.toml" - pyproject.write_text(project_text("ruff==0.16.2", "semgrep==1.172.0"), encoding="utf-8") + text = project_text("ruff==0.16.2", "semgrep==1.172.0") + if include_tooling: + text = text.replace("dev = [", 'dev = [\n { include-group = "tooling" },') + 'tooling = ["research-repo-tools==0.1.7"]\n' + pyproject.write_text(text, encoding="utf-8") calls: list[tuple[str, list[str], dict[str, object]]] = [] def fake_run(command: str, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: diff --git a/scripts/update_python_dev_pins.py b/scripts/update_python_dev_pins.py index d857e3d..83fa4ae 100644 --- a/scripts/update_python_dev_pins.py +++ b/scripts/update_python_dev_pins.py @@ -56,7 +56,7 @@ def _python_floor(project: dict[str, object]) -> str: def _dev_pins(groups: dict[str, object]) -> list[DevPin]: - """Return validated exact pins from dependency-groups.dev.""" + """Return direct exact dev pins, leaving included groups under their owners.""" dev = groups.get("dev") if not isinstance(dev, list): msg = "dependency-groups.dev must be an array" @@ -68,8 +68,14 @@ def _dev_pins(groups: dict[str, object]) -> list[DevPin]: pins: list[DevPin] = [] normalized_names: set[str] = set() for requirement in dev: + if isinstance(requirement, dict) and set(requirement) == {"include-group"}: + included = requirement["include-group"] + if not isinstance(included, str) or included == "dev" or not isinstance(groups.get(included), list): + msg = "include-group must name an existing dependency group other than dev" + raise ValueError(msg) + continue if not isinstance(requirement, str): - msg = "dependency-groups.dev entries must be strings" + msg = "dependency-groups.dev entries must be strings or include-group tables" raise TypeError(msg) requirement_match = EXACT_REQUIREMENT.fullmatch(requirement) if requirement_match is None: @@ -162,7 +168,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "--pyproject", type=Path, default=Path("pyproject.toml"), - help="project manifest containing exact dependency-groups.dev pins", + help="project manifest containing direct exact dependency-groups.dev pins", ) return parser.parse_args(argv) From 2372f81639838b4f3a767ff021c3ca766aaba63e Mon Sep 17 00:00:00 2001 From: Adam <adam@adamgetchell.org> Date: Wed, 30 Sep 2026 18:36:25 +0000 Subject: [PATCH 3/5] test(changelog): stabilize disposable consumer fixtures - Pin the synthetic Cargo identity independently of the checkout's release version while retaining the real consumer configuration and policy. - Disable inherited commit and tag signing only inside disposable repositories so fixture operations do not require user signing keys. Refs #254 --- scripts/tests/test_changelog_integration.py | 58 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/scripts/tests/test_changelog_integration.py b/scripts/tests/test_changelog_integration.py index f8e5093..bf292fe 100644 --- a/scripts/tests/test_changelog_integration.py +++ b/scripts/tests/test_changelog_integration.py @@ -18,15 +18,23 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +def create_consumer(root: Path, source: Path) -> Path: + """Use real configuration and policy with a stable synthetic package identity.""" + for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "README.md"): + shutil.copyfile(source / name, root / name) + (root / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.4.6"\n', encoding="utf-8") + run_git_command(["init", "--quiet"], cwd=root) + run_git_command(["config", "user.name", "Changelog fixture"], cwd=root) + run_git_command(["config", "user.email", "fixture@example.invalid"], cwd=root) + run_git_command(["config", "commit.gpgsign", "false"], cwd=root) + run_git_command(["config", "tag.gpgsign", "false"], cwd=root) + return root + + @pytest.fixture def consumer(tmp_path: Path) -> Path: - """Use real consumer configuration and policy in a disposable Git repository.""" - for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "Cargo.toml", "README.md"): - shutil.copyfile(REPO_ROOT / name, tmp_path / name) - run_git_command(["init", "--quiet"], cwd=tmp_path) - run_git_command(["config", "user.name", "Changelog fixture"], cwd=tmp_path) - run_git_command(["config", "user.email", "fixture@example.invalid"], cwd=tmp_path) - return tmp_path + """Create one disposable consumer independent of live release and signing state.""" + return create_consumer(tmp_path, REPO_ROOT) def cli(root: Path, *args: str) -> int: @@ -301,6 +309,42 @@ def test_tag_accepts_a_declared_release_date_before_the_tagging_day(consumer: Pa assert markdown_bytes(consumer) == before +def test_fixture_isolates_commits_and_tags_from_global_signing(consumer: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Synthetic Git operations need no key when ambient signing is enabled.""" + signing_config = consumer / "global-signing.gitconfig" + signing_config.write_text("[commit]\ngpgsign = true\n[tag]\ngpgsign = true\n[gpg]\nprogram = fixture-signing-must-not-run\n", encoding="utf-8") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(signing_config)) + commit(consumer, "feat: signing isolation fixture") + released = datetime.now(UTC).date().isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- Unsigned fixture tag.\n", encoding="utf-8") + assert cli(consumer, "tag", "v0.4.6") == 0 + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=consumer).stdout.strip() == "tag" + + +def test_fixture_tags_remain_valid_after_the_source_release_version_advances(tmp_path: Path) -> None: + """A release update in the checkout cannot invalidate synthetic tag scenarios.""" + source = tmp_path / "advanced-source" + source.mkdir() + for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "README.md"): + shutil.copyfile(REPO_ROOT / name, source / name) + manifest = source / "pyproject.toml" + text = manifest.read_text(encoding="utf-8") + version_before = tomllib.loads(text)["project"]["version"] + manifest.write_text(text.replace(f'version = "{version_before}"', 'version = "0.4.7"', 1), encoding="utf-8") + (source / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.4.7"\n', encoding="utf-8") + root = tmp_path / "consumer" + root.mkdir() + create_consumer(root, source) + commit(root, "feat: next source release fixture") + released = datetime.now(UTC).date().isoformat() + (root / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- Stable fixture identity.\n", encoding="utf-8") + (root / "CITATION.cff").write_text(f"version: 0.4.6\ndate-released: {released}\n", encoding="utf-8") + assert tomllib.loads((source / "Cargo.toml").read_text(encoding="utf-8"))["package"]["version"] == "0.4.7" + assert tomllib.loads(manifest.read_text(encoding="utf-8"))["project"]["version"] == "0.4.7" + assert cli(root, "tag", "v0.4.6") == 0 + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=root).stdout.strip() == "tag" + + def test_normalization_preserves_fenced_examples_and_prerelease_notes(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: """Rust code, Markdown links, and fenced release examples remain literal.""" (consumer / "CHANGELOG.md").write_text( From 8a06208337a1e5f9d0ad638c45be478ae17e8200 Mon Sep 17 00:00:00 2001 From: Adam Getchell <acgetchell@ucdavis.edu> Date: Wed, 30 Sep 2026 11:49:25 -0700 Subject: [PATCH 4/5] chore(deps): bump development tools and refresh dependency locks - Advance Cargo CLI pins and align the uv pin with version 0.12.19. - Upgrade actionlint-py, Ruff, Semgrep, and Ty development requirements. - Refresh Cargo and Python locked dependencies while retaining the research-repo-tools 0.1.7 pin. Refs #254 --- Cargo.lock | 92 +++++------ justfile | 14 +- pyproject.toml | 8 +- uv.lock | 427 +++++++++++++++++++++++++------------------------ 4 files changed, 277 insertions(+), 264 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8490fa6..43cf087 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,9 +70,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bumpalo" @@ -91,13 +91,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -114,9 +114,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.4.5" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "shlex", @@ -124,9 +124,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "ciborium" @@ -157,18 +157,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstyle", "clap_lex", @@ -176,9 +176,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "criterion" @@ -377,9 +377,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fnv" @@ -577,9 +577,9 @@ checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" [[package]] name = "glam" -version = "0.33.7" +version = "0.33.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e762b9b188634fc508d4ec54b62ea3d352c43fd431d18d05d46f559f217f4725" +checksum = "35b4e837a1d133645d1dcf283e146a4b9901e390a1c13545f959cfed3a72f24f" [[package]] name = "half" @@ -634,9 +634,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -712,7 +712,7 @@ dependencies = [ "glam 0.30.10", "glam 0.31.1", "glam 0.32.1", - "glam 0.33.7", + "glam 0.33.11", "matrixmultiply", "num-complex", "num-rational", @@ -1140,9 +1140,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1220,7 +1220,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1284,9 +1284,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -1364,9 +1364,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "version_check" @@ -1404,9 +1404,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -1417,9 +1417,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1427,31 +1427,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -1459,9 +1459,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf05ca94c9fba0c51316899caa1d1e74a064fc310a5efa7375e614343d415be" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" dependencies = [ "bytemuck", "safe_arch", @@ -1521,18 +1521,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/justfile b/justfile index e2815ce..554af77 100644 --- a/justfile +++ b/justfile @@ -20,18 +20,18 @@ _coverage_base_args := '''--features exact \ cargo_edit_version := "0.13.13" cargo_llvm_cov_version := "0.9.1" cargo_machete_version := "0.9.2" -cargo_nextest_version := "0.9.143" +cargo_nextest_version := "0.9.146" cargo_update_version := "22.1.1" clippy_sarif_version := "0.8.0" -dprint_version := "0.57.4" -git_cliff_version := "2.14.1" +dprint_version := "0.58.0" +git_cliff_version := "2.14.2" just_version := "1.58.0" -rumdl_version := "0.2.68" +rumdl_version := "0.2.78" sarif_fmt_version := "0.8.0" taplo_version := "0.10.0" -typos_version := "1.50.1" -uv_version := "0.12.10" -zizmor_version := "1.30.0" +typos_version := "1.50.3" +uv_version := "0.12.19" +zizmor_version := "1.30.1" # Internal helpers: ensure external tooling is installed _ensure-actionlint: _ensure-uv diff --git a/pyproject.toml b/pyproject.toml index ce2e116..59afb79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,13 +150,13 @@ package = true tooling = [ "research-repo-tools==0.1.7" ] dev = [ { include-group = "tooling" }, - "actionlint-py==1.7.12.24", + "actionlint-py==1.7.12.25", "pytest==9.1.1", - "ruff==0.16.6", - "semgrep==1.176.1", + "ruff==0.16.9", + "semgrep==1.178.0", "shellcheck-py==0.11.0.1", "shfmt-py==4.2.0", - "ty==0.0.79", + "ty==0.0.84", "yamllint==1.38.0", ] diff --git a/uv.lock b/uv.lock index 757d1a7..4335a0f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,9 +4,9 @@ requires-python = ">=3.14" [[package]] name = "actionlint-py" -version = "1.7.12.24" +version = "1.7.12.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/0b/3f29683dfbe94208fb5c3806806a6ef419972892e25c3c4f95198f68c978/actionlint_py-1.7.12.24.tar.gz", hash = "sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e", size = 12071, upload-time = "2026-03-31T06:21:35.015Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/75/36ee89db39bb3620d1e46fe1f6ec77d6000d30ff45345699e51b4c850e83/actionlint_py-1.7.12.25.tar.gz", hash = "sha256:83f83be4cd317c79b15cbb7022787513a6994f00bfb2dd8da02fa4e62b4f640d", size = 12438, upload-time = "2026-09-22T16:24:42.477Z" } [[package]] name = "annotated-types" @@ -127,96 +127,96 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, - { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, - { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, - { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, - { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, - { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, - { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, - { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, - { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, - { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, - { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, - { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, - { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, - { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, - { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, - { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, - { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, - { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, - { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, - { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, - { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, - { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, - { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, - { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, - { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, - { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, - { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, - { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, - { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, - { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, - { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, - { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, - { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, - { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, - { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, - { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, - { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, - { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, - { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, - { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, - { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, ] [[package]] @@ -254,52 +254,65 @@ wheels = [ [[package]] name = "cryptography" -version = "50.0.1" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, - { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, - { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, - { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, - { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, - { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, - { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, - { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, - { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, - { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, - { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, - { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, - { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, - { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, - { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, - { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] [[package]] @@ -339,14 +352,14 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.75.3" +version = "1.75.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/2b/6ce81972d5c8cab9705fddce3153be63222d9e12fd96f8baba5038a744dd/googleapis_common_protos-1.75.5.tar.gz", hash = "sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72", size = 156513, upload-time = "2026-09-29T19:26:14.863Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" }, + { url = "https://files.pythonhosted.org/packages/65/b9/6b29500a1c581ff4d77fd83c6568d068bee06f1b139fb6eb0a4f2d4bce8a/googleapis_common_protos-1.75.5-py3-none-any.whl", hash = "sha256:d7285525c23039db98f2463e6d5a4f9b958b94d497f03a844ece3259c4e72d5d", size = 307737, upload-time = "2026-09-29T19:25:48.735Z" }, ] [[package]] @@ -397,11 +410,11 @@ wheels = [ [[package]] name = "idna" -version = "3.19" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -477,14 +490,14 @@ tooling = [ [package.metadata.requires-dev] dev = [ - { name = "actionlint-py", specifier = "==1.7.12.24" }, + { name = "actionlint-py", specifier = "==1.7.12.25" }, { name = "pytest", specifier = "==9.1.1" }, { name = "research-repo-tools", specifier = "==0.1.7" }, - { name = "ruff", specifier = "==0.16.6" }, - { name = "semgrep", specifier = "==1.176.1" }, + { name = "ruff", specifier = "==0.16.9" }, + { name = "semgrep", specifier = "==1.178.0" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, { name = "shfmt-py", specifier = "==4.2.0" }, - { name = "ty", specifier = "==0.0.79" }, + { name = "ty", specifier = "==0.0.84" }, { name = "yamllint", specifier = "==1.38.0" }, ] tooling = [{ name = "research-repo-tools", specifier = "==0.1.7" }] @@ -1043,27 +1056,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, - { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, - { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, - { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, - { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, - { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, - { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, - { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, - { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, - { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, - { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] [[package]] @@ -1101,7 +1114,7 @@ wheels = [ [[package]] name = "semgrep" -version = "1.176.1" +version = "1.178.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1132,15 +1145,15 @@ dependencies = [ { name = "urllib3" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/f3/04449eca8f3f249c3a0943e4bdf2cd8d5bd8b1a3f0fe10124dfe9a790a4e/semgrep-1.176.1.tar.gz", hash = "sha256:670e2dc84cc9b7a3b42e42e0b7137b06638cad49cb3166a57377257137d21bad", size = 507827, upload-time = "2026-09-04T20:28:13.453Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/3c4bfea2fc57d5be41976d28386bccee46e4fc0467a7524e43900762354c/semgrep-1.178.0.tar.gz", hash = "sha256:0267a4fb850da85dd0d804487699c21995a2d25029afb6e655826371fd13fbb4", size = 518079, upload-time = "2026-09-23T21:11:18.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/1e/2b73dfe585aeee782f08c722d53a2fbedf71f04e812af872dbe0c56d80c8/semgrep-1.176.1-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", hash = "sha256:e1f78275f13c11bd9b6af1143befd79d333d948bd4af96768be3494ab342d8f3", size = 45920492, upload-time = "2026-09-04T20:29:48.859Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a2/f10735897b51b0721a1c239fd1fdb4ac8f9e08dae8698da0bc1737ccf554/semgrep-1.176.1-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:ce35dc0b9c34bb95e16f487699646a461e9b2aceda073e773526e0836dbf73ba", size = 49902432, upload-time = "2026-09-04T20:29:51.852Z" }, - { url = "https://files.pythonhosted.org/packages/8e/c9/c1a89c9bb7489199bca3cf3f4c8339c09fd28e4c58a81eed9cbb1c5edaba/semgrep-1.176.1-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", hash = "sha256:9baed01491cbe1de00f862f73349bf1c9045598bae2923f6a19ef453b3092015", size = 72644931, upload-time = "2026-09-04T20:29:54.826Z" }, - { url = "https://files.pythonhosted.org/packages/17/97/ee08082667af4c3f0da56ad2af4f1e4cbd0b375200235c4ea6e1095b6742/semgrep-1.176.1-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", hash = "sha256:00bc0f167564443d7ce4ad7a896d2251ed3c5c17e0b176de9b39bd0fb6abc2a9", size = 70574159, upload-time = "2026-09-04T20:29:58.512Z" }, - { url = "https://files.pythonhosted.org/packages/98/f5/50835e1c5e79a4db04d06cf6f52419dfd065158daaea96bf49692901081b/semgrep-1.176.1-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", hash = "sha256:2214b71919b825766844ce3dc8c868965d98c3109628882357461ba2a1b8def7", size = 79570338, upload-time = "2026-09-04T20:30:01.807Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e4/53b3478a6ec1ee8129131a216ef763c8850c321ac787e4d66d99bc710cb1/semgrep-1.176.1-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f1127deb8df4e671bae2ec6c811cb069df29fae66bf4880bdad135f38629b23", size = 77161773, upload-time = "2026-09-04T20:30:06.664Z" }, - { url = "https://files.pythonhosted.org/packages/61/d5/6eecdbb00740d801f4e58a16355dd72746171cb7607dac1156d475496a39/semgrep-1.176.1-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:5ca9822b9b8d645b6f078160139b966bd66a3829b87c4e63e1bb5f8d7f71732f", size = 58115254, upload-time = "2026-09-04T20:30:09.998Z" }, + { url = "https://files.pythonhosted.org/packages/45/3c/7331cfac09ae6942514b902b840b47573f1a9aa8d0e6938f953c36cc88db/semgrep-1.178.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", hash = "sha256:ba18dbf4f293ed20834b69aae62b9352a089f2c9308e66f6eb43d67ca780e633", size = 46032107, upload-time = "2026-09-23T21:12:40.573Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/81080365a07d2ebf737fe22c2934d6a0a80e1f90982474b6b1b01ba81e4a/semgrep-1.178.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:33eb20066093799ab5fb9c4d3722e891d2bfb89218a145762a1f4397642b9ad2", size = 50025933, upload-time = "2026-09-23T21:12:44.125Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/b71f6065b429a03c89d50e5bfb927c465eca881af838708951fbd7dc1125/semgrep-1.178.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", hash = "sha256:a64fb4ed4fc5b68016381085088f732a58cfd9a87b16c6ab70c12aac784bd17d", size = 72775657, upload-time = "2026-09-23T21:12:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bf/5bc9cc1b1e1650467ceb6361306d170dd3641b911efa2a89bc7129be4d54/semgrep-1.178.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", hash = "sha256:b7c4a4ba5cad1a6b0e76f7143c164b3f2853b9d0f902f2db2f64006257c941f2", size = 70710092, upload-time = "2026-09-23T21:12:52.067Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9e/d547819f5ecb03df41d316b7831a0739141ddedb4ac3f0f87448159b2b38/semgrep-1.178.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", hash = "sha256:7a4083761f253d9354612b0509ec53243c6e1d95ad9612d74cac3c0e54e7d523", size = 79704144, upload-time = "2026-09-23T21:12:55.988Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/a2592075722c15a72bc7a51b620aac81c7bb1210595838da16ddab523ca6/semgrep-1.178.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", hash = "sha256:7e0dfdcf5708f132a0f3d243a24ba9bbbf5a62a7a78fc182a537b3cbdc641cd4", size = 77300937, upload-time = "2026-09-23T21:12:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/3c/72/bc45bc3610855fd6df068fd4ab617765a86c7dde6dfeacc56a0269da4a61/semgrep-1.178.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:22215f444af400ca03dc57bdebbf0b34fc7a2b80a5ce0081adff02198c55f1f6", size = 58245452, upload-time = "2026-09-23T21:13:03.188Z" }, ] [[package]] @@ -1170,27 +1183,27 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.4.11" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] [[package]] name = "starlette" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, ] [[package]] @@ -1222,27 +1235,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.79" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/d3/4fff47468a976c7a5ded9fe350734ca09b9e8460750327f2245ea3288d5d/ty-0.0.79.tar.gz", hash = "sha256:159a1aca70edebae32be08bfba2e5d543ffd8f9f380af160e0e713e85313b733", size = 7162150, upload-time = "2026-09-07T21:51:58.065Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/a3/2fa5b5495fe37351d77571eb0985476461be02c822bb39ad669b9fdb5bb8/ty-0.0.79-py3-none-linux_armv6l.whl", hash = "sha256:f60d968bbc6d52b663d3df4cae647d3cc68df4a7135bd9098e8d570bf9a5e0da", size = 13557478, upload-time = "2026-09-07T21:51:14.089Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f0/1659a926d2b19351839ca64787e1531c97fbe8bf35c3c47d20954f338428/ty-0.0.79-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4c95ae0ca67483c4f1231c4b2fd332b76f212e8d93154c385d77511d90dcbd8e", size = 13163780, upload-time = "2026-09-07T21:51:17.066Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b2/e0a8a8cf58b39f0d1f509a22550aed1e2cc12bb4a868170b43aede9fcd93/ty-0.0.79-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685888adb29b6e732ee325c6b6db92a422f72f3e4031c12450728c846e5e93fe", size = 12964040, upload-time = "2026-09-07T21:51:19.818Z" }, - { url = "https://files.pythonhosted.org/packages/88/a7/7d8fb6c958d88a63ad932421eff8848267bb9229136ef65d9f372aa2be7a/ty-0.0.79-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0535640db5dc02f9e14bf419ad2cf3acdd49681e70f233c96e96d16cfcefc00", size = 13010025, upload-time = "2026-09-07T21:51:22.468Z" }, - { url = "https://files.pythonhosted.org/packages/e3/4c/94aee26446f058e67b2c8ef0b25bdc1cf555e40eb692112d7a609f30c238/ty-0.0.79-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48cb24e21488f9d11cffa81d5a5f71f314c87383b7a5d28dabe6feaa3f3d7a34", size = 13332191, upload-time = "2026-09-07T21:51:25.031Z" }, - { url = "https://files.pythonhosted.org/packages/6c/21/9e7c0415fa6275500f10ce13f2bad62e03211ba5f3ea61a702d819328407/ty-0.0.79-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a35b3b116a591a68da5958c082247f0c3a50f134d0a60ff45d060eb3d2ad612", size = 14153955, upload-time = "2026-09-07T21:51:27.421Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5f/b27b510f973a314200cfda4f0500de3c65f6caa45a590355f6acc5e96289/ty-0.0.79-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcb760f059d660dfd5c9c6c33609478a267c3185c7448d72e6f7ff4572796bae", size = 14594578, upload-time = "2026-09-07T21:51:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/a6/fa/0ccc2e510c1c24682ae21c836c09be6dcf0b1aacab071c51a297d301601f/ty-0.0.79-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcdc1ae0c740f9b6d6536ffecf485616a8909bed5de88c6e870a65546a6a2d91", size = 14266409, upload-time = "2026-09-07T21:51:32.868Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c3/3a404d44e9768578daf7dfda06cba714eda6feac53272eda295947a8d4c7/ty-0.0.79-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c1e77d58e192c81b958783328b303ceda8706328a307c852ceb257505765458", size = 13658441, upload-time = "2026-09-07T21:51:35.283Z" }, - { url = "https://files.pythonhosted.org/packages/f6/23/b90a512055fdf6356e95c0f745cb9b443b9a4ac9d3a79f07ae6ed53ae9ca/ty-0.0.79-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a87f52183b976d9eda5fad28405b37621a265c3de5e9e279660556c715ed1805", size = 14195669, upload-time = "2026-09-07T21:51:37.848Z" }, - { url = "https://files.pythonhosted.org/packages/b0/05/d051248dcff852e8a3165a2feb164dc5b857ef237fa82e7ea2b3b7bac375/ty-0.0.79-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb429a44bc9e90649e69f739d70db4a305102a36586a810caee00c984c379997", size = 13127929, upload-time = "2026-09-07T21:51:40.431Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d1/60b5c980df55b1d5010a41b10d7c8cf6111b5d02ec0807f6f782bb3d88aa/ty-0.0.79-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1d857fa34d60b0981cf12ca19180ae5de70dd46306a9277bee772bb09e82ae5c", size = 13334425, upload-time = "2026-09-07T21:51:42.956Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/fcbfa731128f06b1c9be5074f51ce47da06842dc8f33c81e7c1c483b6e91/ty-0.0.79-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a95cffa2f30289b0b949db7611ae96ede0352f0f1d7ba07ea7cabb27616fccb9", size = 13629671, upload-time = "2026-09-07T21:51:45.467Z" }, - { url = "https://files.pythonhosted.org/packages/0e/00/3004a1d375c2a835c7dbf01c480205a190f24b74c8b5c849fd2dbe078d69/ty-0.0.79-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d33df0f1bdee62dc366551d35b9830b1fa2e9ee2936a437abd435f71b3fce73e", size = 13939769, upload-time = "2026-09-07T21:51:48.033Z" }, - { url = "https://files.pythonhosted.org/packages/10/7d/f357f5768872ffa3a026707477a880aba7582af5f6c7c7eab74f61167f3d/ty-0.0.79-py3-none-win32.whl", hash = "sha256:ca266de079a187ed6f0f5b802f6fc3b26164753c7347a04daed21d86c938d33d", size = 12833644, upload-time = "2026-09-07T21:51:50.766Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f5/2a2d967be6286ee8fb626161e610fd48624ba5e6712cea932adb2d6b40b6/ty-0.0.79-py3-none-win_amd64.whl", hash = "sha256:88cb357d36ad79181015581365769fff4b1ec580cd12de83addfa98663214fd3", size = 13494258, upload-time = "2026-09-07T21:51:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/8b/74/49b88f104f88d9757497758bf57ba53e3612442b49500d1092d4d6f1daa3/ty-0.0.79-py3-none-win_arm64.whl", hash = "sha256:d29da73f2840ae2631bc62ef8f8d509a94edea596e5b3072851f67e4729d744c", size = 13327517, upload-time = "2026-09-07T21:51:55.602Z" }, +version = "0.0.84" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" }, + { url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" }, + { url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" }, ] [[package]] @@ -1268,24 +1281,24 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] name = "uvicorn" -version = "0.52.4" +version = "0.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, ] [[package]] From 1572957a4ada1f8fba900ac18f9378e9e0366ba0 Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Wed, 30 Sep 2026 14:52:18 -0700 Subject: [PATCH 5/5] refactor(changelog): adopt common template and draft reviews - Use the shared 0.1.7 changelog format with a bounded exception for historical deps-dev release-note bodies. - Preserve literal Rust code during generation and retain complete release history, dates and links. - Enable automatic CodeRabbit draft reviews and align zizmor-action with the existing scanner pin. Refs #254 Refs acgetchell/research-repo-tools#62 --- .coderabbit.yaml | 2 + .github/workflows/zizmor.yml | 2 +- CHANGELOG.md | 180 +++++++++++++++++--- cliff.toml | 37 ++-- docs/archives/changelog/0.3.md | 2 +- pyproject.toml | 2 +- scripts/README.md | 14 +- scripts/tests/test_changelog_integration.py | 26 ++- 8 files changed, 212 insertions(+), 53 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index e6c1bf6..065403b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -5,6 +5,8 @@ early_access: false reviews: profile: chill request_changes_workflow: true + auto_review: + drafts: true # Repository-owned checks validate deliberately invalid static-analysis fixtures. path_filters: - "!tests/semgrep/**" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 9054d74..2a69414 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -50,7 +50,7 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run zizmor - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 with: inputs: .github online-audits: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1549423..3cb7872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### โš ๏ธ Breaking Changes + +- changelog-unreleased now requires TAG DATE and leaves + release metadata preparation separate. Replace the removed standalone + archive-changelog, postprocess-changelog and tag-release entry points with + research-repo-tools changelog commands. Tags must use v-prefixed SemVer; + tagging requires today's UTC release date matching CITATION.cff. + +### Changed + +- [**breaking**] Adopt pinned shared changelog commands [`ce72843`](https://github.com/acgetchell/la-stack/commit/ce72843d3c7e5c2ed6d97955ed6ce3707acb784f) + + - Delegate generation, normalization, minor archiving, release notes and + local tag preparation to the published research-repo-tools 0.1.7 CLI. + - Preserve retained release history and dependency-note links while moving + completed series to docs/archives/changelog. + - Keep the historical git-cliff policy as a documented consumer exception + and validate published changelog links alongside the shared checks. +- Stabilize disposable consumer fixtures [`2372f81`](https://github.com/acgetchell/la-stack/commit/2372f81639838b4f3a767ff021c3ca766aaba63e) + + - Pin the synthetic Cargo identity independently of the checkout's release + version while retaining the real consumer configuration and policy. + - Disable inherited commit and tag signing only inside disposable repositories + so fixture operations do not require user signing keys. + +### Dependencies + +- Bump development tools and refresh dependency locks [`8a06208`](https://github.com/acgetchell/la-stack/commit/8a06208337a1e5f9d0ad638c45be478ae17e8200) + +### Fixed + +- Preserve consumer command and release contracts [`cb8c471`](https://github.com/acgetchell/la-stack/commit/cb8c471511bf4f7f5f50cdfe3d7a0296e3357f14) + + - Forward preview options to the shared CLI and keep generated archives under + the dedicated changelog formatter and final link check. + - Accept valid included dependency groups while advancing only direct dev + pins, leaving the shared tooling dependency under its own upgrade policy. + - Keep declared release dates valid after a later merge and include canonical + changelog archives in the release staging guidance. + ## [0.4.6] - 2026-09-08 ### โš ๏ธ Breaking Changes @@ -13,6 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - rename Vector::norm2() to Vector::norm(), Vector::norm2_sq() to Vector::norm_squared(), and Matrix::inf_norm() to Matrix::norm_inf(). No compatibility aliases are provided. - la-stack now requires Rust 1.98.1. +- New release performance comparisons require v0.4.4 or newer on both sides. Direct Cargo invocations of vs_linalg must select + -p la-stack-comparison; the just recipes handle this automatically. ### Merged Pull Requests @@ -30,21 +74,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - add release-tracked Criterion comparisons against BigRational Gaussian elimination - document the two-domain scalar model and the f64 precision boundary - Add certified determinant sign enclosures [`cd94ebb`](https://github.com/acgetchell/la-stack/commit/cd94ebbbfecccc1fc79887eec39f1e2b8a3fc56a) + - Add outward-rounded interval arithmetic with typed range and input errors. - Certify determinant signs through D=7 with explicit inconclusive results. - Provide runtime dimension dispatch, documentation, and benchmark coverage. - Refresh pinned Python development tools and transitive dependencies. - Add certified dot and affine error bounds [`973e662`](https://github.com/acgetchell/la-stack/commit/973e662bfa1bb85631f6826034f128b181a326c4) + - Add proof-bearing scalar certificates for dot products and unrounded axis ยท (left - right) reductions. - Expose outward bounds for sign and threshold filtering, with inconclusive results when proof conditions fail. - Document and benchmark the deterministic FMA error model. - Add overflow-safe Euclidean norms [`f1f7b11`](https://github.com/acgetchell/la-stack/commit/f1f7b11d873db9c70c7db9920ee36efa7b4ed801) + - Add allocation-free Vector::norm2 with scaled accumulation for large and subnormal finite coordinates. - Resolve upper-range rounding with exact stack-based square sums, preventing false or hidden overflow without the exact feature. - Report VectorNorm-tagged NonFinite errors only when the exact norm rounds to infinity; preserve norm2_sq's distinct range contract. - Avoid redundant first-coordinate arithmetic and share binary64 rounding primitives across vector and interval operations. - Document approximation limits and add peer-crate norm benchmarks plus scenario comparisons with hypot and Delaunay reference kernels. - Add const-generic Gram matrix construction [`a62e72a`](https://github.com/acgetchell/la-stack/commit/a62e72a0468ea13c7c5e851dbf195123d5bb0d38) + - Add allocation-free, const-evaluable `gram_matrix` with independent vector count and dimension - Preserve bitwise symmetry and typed dot-product overflow diagnostics - Document geometric uses, conditioning, and floating-point limitations @@ -61,6 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cover determinant accumulation failures, square/addition exhaustion, and matrix access bounds. - Clarify that lifting an already-rounded matrix encloses only its stored values. - [**breaking**] Standardize vector and matrix norm names [`ab483d4`](https://github.com/acgetchell/la-stack/commit/ab483d4c4cb96b2d3f2ba0be38e96f5820b0de91) + - Distinguish Euclidean norm from squared norm with clearer vector APIs. - Use norm_inf for the matrix maximum absolute row sum. - Preserve numerical behavior and typed error contracts. @@ -84,36 +133,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve runtime and build-system dependency requirements. - Refresh Ruff, Semgrep, Ty, and their compatible transitive dependencies. - Enforce the f64 algebraic-operation ban [`753179c`](https://github.com/acgetchell/la-stack/commit/753179c001ce750195b85bf7bc680cca0558eb34) + - Reject algebraic f64 calls and function items across repository-owned Rust while preserving ordinary operators and `mul_add`. - Document the numerical contract, MSRV, release-pinned references, and native BLAS alternatives. - Refresh contributor tool pins and adapt subprocess encoding to Ty 0.0.74. - Detect qualified f64 algebraic operations [`31021f1`](https://github.com/acgetchell/la-stack/commit/31021f1322a9c443fa4df63e632e8038600b2461) - - Reject `<f64>::algebraic_*` calls and function items. + + - Reject `<f64>::algebraic_*` calls and function items. - Preserve qualified FMA calls and function items as allowed. - Make just update reconcile all managed tools [`b1ca30a`](https://github.com/acgetchell/la-stack/commit/b1ca30a3f3977d3504b9383aff3907ce73f5a72a) + - Preflight update prerequisites before modifying dependency or lock state. - Update Cargo and Python dependencies while preserving coupled exact-arithmetic requirements. - Upgrade setup-owned Cargo tools and atomically reconcile their pins with the active uv version. - Keep actionlint and zizmor compatible for local composite-action references. - Validate uv before repository updates [`37a17ed`](https://github.com/acgetchell/la-stack/commit/37a17edc82488ff0f04ec5775fe1eb13760b0468) + - Require the active uv to report a stable X.Y.Z version before dependency or Cargo tool changes. - Preserve pin reconciliation for newer stable uv installations. - Reject ambiguous uv output before repository updates [`6bb2412`](https://github.com/acgetchell/la-stack/commit/6bb24127cca69f5029346b63fa9bb330381ae9c7) + - Validate uv output with the reconciler's single-stable-version parser before dependency or tool mutations. - Accept newer stable uv releases while rejecting ambiguous, missing, prerelease, and embedded versions. - Refresh managed tool pins and the Semgrep development dependency. - Make Python automation portable on Windows [`2370cbf`](https://github.com/acgetchell/la-stack/commit/2370cbf819f893ca598f83394330a5936fd10c6b) + - Bind uv version validation to the exact launcher selected by update recipes - Preserve byte-exact Git input and explicit text newline policies - Guard repository scripts against platform-dependent subprocess and file I/O - Enforce complete Python type annotations [`b2be4e7`](https://github.com/acgetchell/la-stack/commit/b2be4e73c19d0566efd9d7854e325375287c5e83) + - Enable missing-annotation checks for function parameters and methods - Format and type-check repository-owned Python fixtures alongside scripts - Preserve intentional negative fixtures with narrow lint suppressions - Enforce Python type-only import linting [`dd3d399`](https://github.com/acgetchell/la-stack/commit/dd3d399bf181aea7697c0cca1b6a60f67a39cd2b) + - Apply Ruff's TC rules to Python static-analysis fixtures. - Keep annotation-only dependencies out of runtime imports. - Enforce complete Python fixture linting [`ebc45fe`](https://github.com/acgetchell/la-stack/commit/ebc45fefeb99eec72c13c939d3eb5c3505bf1114) + - Run the full configured Ruff policy over Python Semgrep fixtures in canonical CI. - Isolate deliberate fixture violations with narrow per-file suppressions. - Exclude static-analysis fixtures from CodeRabbit review and leave docstring policy to Ruff. @@ -126,14 +184,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - make release comparisons capability-aware for pre-rational benchmark baselines - clarify exact-input guarantees, f64 precision loss, and benchmark provenance - Make rational comparisons fair and backward-compatible [`67afc8e`](https://github.com/acgetchell/la-stack/commit/67afc8e61345e1993680c315223e544ea1800937) + - Exclude input cloning from consuming BigRational reference timings. - Support schema-1 artifacts that predate rational-input provenance. - Omit unsupported rational-input rows from legacy coverage checks. - Avoid false certificate logging alerts [`d8d21db`](https://github.com/acgetchell/la-stack/commit/d8d21dbf76d7a4fa3ba15f37a6086179e7394976) + - Rename numerical-bound locals so CodeQL does not mistake them for sensitive certificate data. - Document finite bound invariants, the affine error formula, and typed failure contexts. - Make proof-range and second-FMA overflow expectations explicit. - Complete benchmark CI and improve documentation navigation [`ee1c60f`](https://github.com/acgetchell/la-stack/commit/ee1c60f72a7570deae10aa90e54141c9422e4322) + - Allow the full exact benchmark suite to finish while preserving normal sampling and skipping unused plots. - Select successful main baselines and report incomplete comparisons accurately. - Add a canonical 5ร—5 quickstart, linked feature summaries, Contents, and a Documentation Map. @@ -142,11 +203,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document numerical logging false positives and the outstanding paste maintenance advisory. - Update cargo-llvm-cov and rumdl tool pins. - Correct Windows Markdown checks and benchmark summaries [`e7ca1fe`](https://github.com/acgetchell/la-stack/commit/e7ca1fe2bcd172d4000434b87ce9d511ac7a1487) + - Count Unicode characters consistently to prevent false Markdown line-length failures on Windows. - Report benchmark comparisons as unavailable when baseline coverage is missing or incomplete. - Stabilize Documentation Map navigation across GitHub and rustdoc. - Link exact determinant filtering to det_direct_with_errbound(). - Align zizmor audits and harden release cache isolation [`ee091c3`](https://github.com/acgetchell/la-stack/commit/ee091c36e8c6bff299f3536aba6c05ec5c7f659d) + - Synchronize local and CI zizmor versions and personas, enable authenticated online audits, and clearly report offline fallback. - Remove dependency and tool caches from release benchmark production while preserving separate producer and publisher permissions. - Enforce scanner-version and release-cache policies with Semgrep. @@ -155,12 +218,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Trim AGENTS.md into focused linked guides and document file ownership. - Establish documentation filename conventions and align coverage navigation, performance-report links, and generator output paths. - Verify zizmor version sources and clarify numerical docs [`421bcda`](https://github.com/acgetchell/la-stack/commit/421bcdae365145eb2e32e5087e4ef50d1989c43a) + - Require the zizmor resolver to read the canonical justfile pin and publish it without substituting a hard-coded or unrelated version. - Point README mathematical-basis links to the main branch. - Define roundoff-bound notation explicitly, including binary64 unit roundoff and the D and 2D reduction counts. - Document non-finite factor rejection in ScaledProduct. - Exempt intentional Semgrep fixtures from the algebraic-operation prohibition while retaining it for other repository-owned Rust. - Budget and validate release Criterion baselines [`42a3e1f`](https://github.com/acgetchell/la-stack/commit/42a3e1fc2f4db0fee13e7ae2a72295ec7286934f) + - Allow 150 minutes for comparative benchmarks and 90 minutes for exact benchmarks within a 285-minute job, preserving full Criterion sampling. - Require complete, valid raw measurements and matching saved baselines before packaging the archive with its benchmark inventory. - Report suite runtimes and support manual runs without release publication. @@ -174,12 +239,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Place scalar types, API navigation, and features after Quickstart, move Examples before Benchmarks, and update the Contents list. - Sort feature flags, scalar domains, documentation links, examples, and their run commands lexicographically. - Attach benchmark baselines before publishing releases [`0ddf9cf`](https://github.com/acgetchell/la-stack/commit/0ddf9cf31e65a41ddeff2fc996f84222c1f19ebf) + - Require an explicit stable tag and a matching mutable draft release. - Benchmark the resolved tag commit and verify the uploaded archive's size and SHA-256 digest before publishing the draft. - Reuse matching draft assets on publisher retries without overwriting conflicting assets or modifying published releases. - Require fresh draft checks when rerunning benchmarks. - Document draft creation, workflow dispatch, and failed-run recovery. - Isolate release benchmarks from default-branch caches [`d9434f2`](https://github.com/acgetchell/la-stack/commit/d9434f2cfcf20c05a898228e563fd0d1eee63632) + - Run benchmarks from the release tag and check out the workflow's own commit to keep execution in that tag's cache scope. - Reject mismatched dispatch refs and tags moved since dispatch. - Update release instructions to use --ref "$TAG" instead of main. @@ -191,6 +258,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Skip redundant GCD work when clearing rational denominators. - Clarify numerical certificates, error provenance, and mutation guarantees in API documentation. - Sort README API entries and document the existing Gram benchmark suite. +- [**breaking**] Finalize v0.4.6 and preserve benchmark history + [`5c8a514`](https://github.com/acgetchell/la-stack/commit/5c8a51482c94dede10b2d7cb224186c09de76420) + + - Complete the v0.4.6 release PR with updated crate and utility package versions, citation metadata, release documentation, and roadmap + - Generate the release changelog with complete breaking-change descriptions, preserved Rust code formatting, and idempotent summary generation + - Refresh README benchmark assets and the v0.4.6 versus v0.4.5 report + - Preserve every measured local benchmark case in versioned CSV snapshots with means, medians, confidence intervals, sample counts, and provenance + - Regenerate reports and README assets after cleanup or artifact-only commits without rerunning benchmarks + - Publish reports, snapshots, and the latest pointer atomically; reject incomplete inputs, mismatched provenance, and overlapping output paths + - Isolate nalgebra/faer dependencies in the comparison workspace package so exact benchmark builds do not compile peer libraries + - Reuse baseline peer measurements and disable unused Criterion HTML generation to avoid missing-sample errors in filtered runs + - Fix shared-harness target resolution and exact-solve compatibility with supported historical releases + - Use faer's native dot-product kernel under a distinct benchmark identity + - Remove v0.4.3 adapters, missing-row exemptions, and legacy benchmark-name substitutions while preserving historical reports + - Archive the previous performance report and completed optimization studies + - Fix cargo-upgrade version detection during setup +- Restore portable Python validation [`4b6e7a1`](https://github.com/acgetchell/la-stack/commit/4b6e7a15d44f4f3af6b84e5cd7dbd374e0c49f2e) + + - Install git-cliff in CI using the justfile pin and require that version before running Python tests + - Preserve LF line endings in benchmark fixtures to prevent checksum failures on Windows + - Inspect parsed Just recipe bodies when checking benchmark runs so command prefixes and formatting cannot silently omit recipes ### Maintenance @@ -246,6 +334,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump the github-actions group with 7 updates [#200](https://github.com/acgetchell/la-stack/pull/200) [`9728daf`](https://github.com/acgetchell/la-stack/commit/9728dafc026bb2a849bae49feaca6144c5598698) +- Bump the dependencies group with 3 updates [#211](https://github.com/acgetchell/la-stack/pull/211) + [`493068a`](https://github.com/acgetchell/la-stack/commit/493068ac653724959f7083f34d5c909a496314bd) + + Updates the requirements on [ruff](https://github.com/astral-sh/ruff), [ty](https://github.com/astral-sh/ty) and + [setuptools](https://github.com/pypa/setuptools) to permit the latest version. + + Updates `ruff` from 0.16.1 to 0.16.2 + + - [Release notes](https://github.com/astral-sh/ruff/releases) + - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) + - [Commits](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2) + + Updates `ty` from 0.0.66 to 0.0.69 + - [Release notes](https://github.com/astral-sh/ty/releases) + - [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md) + - [Commits](https://github.com/astral-sh/ty/compare/0.0.66...0.0.69) + + Updates `setuptools` to 84.0.0 + - [Release notes](https://github.com/pypa/setuptools/releases) + - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) + - [Commits](https://github.com/pypa/setuptools/compare/v83.0.0...v84.0.0) ### Fixed @@ -254,16 +363,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reject missing or mismatched uv versions with actionable diagnostics. - Reuse the canonical version guard across uv-backed validation helpers. - Harden release artifact promotion [`8b8537b`](https://github.com/acgetchell/la-stack/commit/8b8537b86226285a343976ea987cf2759bf3dd7d) + - Treat CRLF and LF archive reports as equivalent while preserving stored bytes. - Share comparison selection and report-update guidance across generation paths. - Isolate artifact path resolution and retained-artifact rendering. - Clarify that durable benchmark downloads are GitHub Release assets. - Harden numerical and release evidence [`c878701`](https://github.com/acgetchell/la-stack/commit/c8787017122c3e94958e82d327fdab8a305999b8) + - Surface determinant overflow even when error-bound terms underflow. - Measure complete benchmark operations symmetrically and bind retained reports to atomic, validated provenance. - Fail closed on inconsistent release metadata, tags, changelog sections, Semgrep annotations, and recorded CPU provenance. - Align docs.rs feature annotations, citation identifiers, all-target linting, and configurable property-test runs with documented contracts. - Harden release and validation edge cases [`260afb8`](https://github.com/acgetchell/la-stack/commit/260afb85fdc8eaa873bdb64b6d7eae76ab6b9abc) + - Preserve archived changelog bytes during rollback and reject malformed Unreleased headings. - Match overlapping Semgrep fixture spans deterministically and centralize immutable benchmark provenance handling. - Document exact matrix/RHS scaling and the power-of-two factor that preserves linear systems. @@ -275,6 +387,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Promote distinct-release artifacts through performance-doc and keep performance-release atomic and rollback-capable. - Render one consolidated table per benchmark suite and reject invalid release publication states. - Isolate algebraic float fixture scans [`56e848f`](https://github.com/acgetchell/la-stack/commit/56e848fb3dbbc93f2fd4c0b566d71489c96dc857) + - exclude intentional algebraic float violations from production Semgrep scans while preserving direct fixture validation - cover f64 associated-call syntax alongside f32 and receiver forms - forbid dead-code suppressions and remove blanket allowances from static-analysis fixtures @@ -288,43 +401,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Group GitHub Actions updates, remove autopep8, and refresh pinned tooling. - Print an explicit release title in tag-release follow-up guidance. - [**breaking**] Require Rust 1.97.1 [`99bac26`](https://github.com/acgetchell/la-stack/commit/99bac263ae773cc8cb42c418630c8e7dd7d023b8) + - align contributor, CI, release, and Clippy toolchain baselines - compile benchmarks with Cargo's warning policy to preserve cache reuse - publish CodeRabbit's canonical review check for ruleset enforcement - Restore ruleset-compatible status [`5235365`](https://github.com/acgetchell/la-stack/commit/5235365efaf38a0d4035d74a46a7468ad10921d0) + - publish the legacy CodeRabbit commit status required by the main ruleset - keep review progress disabled until CodeRabbit can publish the required check run - Standardize validation and centralize tool pins [`54a7e3a`](https://github.com/acgetchell/la-stack/commit/54a7e3a8e8a640fa5069d5c07fffbf1c1dfab903) + - Compose CI from orthogonal leaf validators with one release-profile nextest pass and separate doctests. - Resolve workflow tooling through the justfile and shared setup action. - Document focused validation, notebook scope, and Rust 1.97 tooling policy. - Refresh development tools, security actions, Dependabot schedules, and lockfiles. - [**breaking**] Require Rust 1.98.0 [`2f9e0a8`](https://github.com/acgetchell/la-stack/commit/2f9e0a83c4fd974f34f87f29409cf1eac4da5343) + - align the MSRV, contributor toolchain, dependency locks, and documented maintenance baseline - forbid algebraic float operations in numerical kernels to preserve IEEE-754 error and reproducibility contracts - add a scoped just update workflow with atomic Cargo tool-pin reconciliation - clean failed changelog and performance staging files without disturbing published artifacts -- Bump the dependencies group with 3 updates [#211](https://github.com/acgetchell/la-stack/pull/211) - [`493068a`](https://github.com/acgetchell/la-stack/commit/493068ac653724959f7083f34d5c909a496314bd) - - Updates the requirements on [ruff](https://github.com/astral-sh/ruff) , [ty](https://github.com/astral-sh/ty) and - [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - - Updates `ruff` from 0.16.1 to 0.16.2 - - - [Release notes](https://github.com/astral-sh/ruff/releases) - - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - - [Commits](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2) - - Updates `ty` from 0.0.66 to 0.0.69 - - [Release notes](https://github.com/astral-sh/ty/releases) - - [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md) - - [Commits](https://github.com/astral-sh/ty/compare/0.0.66...0.0.69) - - Updates `setuptools` to 84.0.0 - - [Release notes](https://github.com/pypa/setuptools/releases) - - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - - [Commits](https://github.com/pypa/setuptools/compare/v83.0.0...v84.0.0) ### Performance @@ -337,10 +433,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### โš ๏ธ Breaking Changes -- Rename `Tolerance::new` to `Tolerance::try_new` and `Matrix::get_checked` to `Matrix::try_get` ; remove `Matrix::set_checked` in favor of `Matrix::set` . +- Rename `Tolerance::new` to `Tolerance::try_new` and `Matrix::get_checked` to `Matrix::try_get`; remove `Matrix::set_checked` in favor of `Matrix::set`. `Vector::dot` and `Vector::norm2_sq` now borrow their operands. `det_sign_exact` is now infallible and returns `DeterminantSign` instead of - `Result<i8, LaError>` . `LaError` variants now use typed reason, location, and origin fields and require `..` in downstream matches. LDLT now requires exact - symmetry, determinant error bounds may be unavailable under gradual underflow, and `ERR_COEFF_2` , `ERR_COEFF_3` , and `ERR_COEFF_4` are no longer exported by + `Result<i8, LaError>`. `LaError` variants now use typed reason, location, and origin fields and require `..` in downstream matches. LDLT now requires exact + symmetry, determinant error bounds may be unavailable under gradual underflow, and `ERR_COEFF_2`, `ERR_COEFF_3`, and `ERR_COEFF_4` are no longer exported by the prelude. - Rust versions earlier than 1.97.0 are no longer supported. @@ -381,6 +477,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - align README, API documentation, examples, and LDLT diagnostics with guarantees over stored values - audit academic references and extend Criterion coverage for determinant error-bound paths - Clarify determinant and LDLT failure contracts [`e257343`](https://github.com/acgetchell/la-stack/commit/e2573434c541a8115750b3a965882f7b484b6be0) + - State the determinant sign condition without ambiguous Markdown delimiters. - Distinguish singular zero pivots from coupled zero-pivot LDLT rejections. - Align citation and contributor guidance with the positive-definite domain. @@ -394,12 +491,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve benchmark provenance, suite-specific fallback commands, and publication rollback guarantees - Harden Windows Git input, changelog links, and version-reference parsing across platforms - Harden exact arithmetic and benchmark publication [`db2fad5`](https://github.com/acgetchell/la-stack/commit/db2fad50087c773f7ae37f181f720da2a9ff4a23) + - add `DeterminantWithErrorBound` for paired determinant estimates and certified bounds - scale exact systems independently and round exact values directly to IEEE-754 - fail benchmark publication closed on invalid samples or mismatched provenance - make release and changelog tooling transactional, path-safe, and Windows-portable - align benchmark CI with pinned local tools and least-privilege publishing - Harden changelog and version generation [`2e1eb2e`](https://github.com/acgetchell/la-stack/commit/2e1eb2e965333fab51934c2b605fe3465e4b57e6) + - Reject tag prefixes embedded in longer non-semver revisions - Preserve fenced examples when stripping Dependabot metadata - Document the conditional exact-solve scaling invariant @@ -417,9 +516,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.0.0...v83.0.0) - Avoid duplicate review checks [`5e15d92`](https://github.com/acgetchell/la-stack/commit/5e15d9250455fd1467e47a735412f65449f30b56) + - Use the legacy required status while preserving automatic approvals. - Add explicit dense D3 determinant coverage and document the D4 error bound. - [**breaking**] Require Rust 1.97.0 [`4033f1f`](https://github.com/acgetchell/la-stack/commit/4033f1f39e411ed066fec808805371979995964b) + - Align Cargo, the pinned toolchain, Clippy, dependency-audit triggers, and maintainer guidance with the new baseline. - Audit Rust 1.97 integer bit helpers against exact-arithmetic hot paths and retain existing operations where alternatives do not preserve performance. - Correct v0.4.3 citation metadata and document exact decomposition and solve scaling accurately. @@ -432,14 +533,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Share D=4 minors while retaining guarded sparse evaluation. - Preserve non-finite handling for mathematically inactive terms. - Restore small-matrix det_sign_exact throughput [`b8bfa9f`](https://github.com/acgetchell/la-stack/commit/b8bfa9fa0c908ea034d294ceb123c8191eff3395) + - Reuse proof-bearing shared minors for D4 determinant and permanent evaluation. - Restore the dense D3 filter while preserving sparse, overflow, and underflow fallbacks. - Require headline benchmarks to exercise the intended filter and document historical harness overhead. - Improve inf_norm throughput [`dc28f98`](https://github.com/acgetchell/la-stack/commit/dc28f9828a1e90b5604e4a55284175e50f84d7e2) + - Check row-sum finiteness once per completed row on the success path. - Replay only overflowing rows to preserve the first failing matrix coordinate. - Preserve left-to-right accumulation and const evaluation across dimensions. - Improve dot and norm2_sq throughput [`ece54d7`](https://github.com/acgetchell/la-stack/commit/ece54d7b2a73111c2bc378a417189e2951a39cbc) + - Check accumulator finiteness once after the success-path reduction. - Replay only non-finite reductions to preserve the first failing step. - Preserve left-to-right fused accumulation and const evaluation. @@ -464,21 +568,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 hidden doctest mirrors - Update CI uv pins to 0.11.19 - Report determinant scale overflow precisely [`928f62b`](https://github.com/acgetchell/la-stack/commit/928f62bba0d837afe04cb8ccb3fbfed6b095d8f7) + - Add a typed LaError::DeterminantScaleOverflow path for exact determinant scale exponent failures - Convert det_exact_f64 directly from the shared Bareiss integer/exponent pair while preserving Overflow for finite-f64 conversion failures - Reuse vector finiteness scanning across raw and proof-bearing constructors - Harden docs version sync checks for reordered inline-table dependency snippets and pruned Markdown traversal - Add release performance comparison workflow [`53b5fde`](https://github.com/acgetchell/la-stack/commit/53b5fde13f3e4afbd3db80d324185a091203cb75) + - Extend vs_linalg with LDLT/Cholesky benchmark rows and shared deterministic inputs. - Add smoke coverage that checks la-stack, nalgebra, and faer agree on benchmark inputs. - Expand bench-compare to support latest-vs-last reports, suite/scope selection, peer baseline context, and clearer malformed Criterion diagnostics. - Document the benchmark methodology, release baseline workflow, roadmap direction, and contributor guidance. - Publish release benchmark baselines [`9497ca5`](https://github.com/acgetchell/la-stack/commit/9497ca5f88dd7800bdf3823123e2a295fcb5ced1) + - Add a release-only benchmark workflow that saves full Criterion baselines for published releases and attaches the archived baseline to the GitHub Release. - Keep the regular benchmark workflow focused on PR and main-branch comparison runs. - Document how to restore archived release baselines for future performance comparisons. - Feat!(api): make Matrix and Vector finite by construction [`1fa2f55`](https://github.com/acgetchell/la-stack/commit/1fa2f55cfac6f249a7e2bf30922901539e580dd8) - [**breaking**] Make exact f64 conversions strict [`8e33f1a`](https://github.com/acgetchell/la-stack/commit/8e33f1a8ec291bfcb6312375969efce076421e96) + - Add explicit rounded exact-to-f64 APIs for determinant and solve results - Report exact conversion failures with typed Unrepresentable reasons - Remove finite proof wrapper APIs now that Matrix and Vector carry finiteness directly @@ -486,26 +594,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update exact benchmarks to distinguish strict Result paths from rounded f64 paths - Document and exercise the rounded fallback pattern for RequiresRounding errors - [**breaking**] Make exact f64 conversions strict [`89f3720`](https://github.com/acgetchell/la-stack/commit/89f3720ecde9f12d7a0f42e79394836615e8fd97) + - Make Matrix and Vector the finite-by-construction public types for exact arithmetic. - Add rounded exact-to-f64 APIs for determinant and solve callers that want explicit lossy conversion. - Return typed Unrepresentable reasons when strict exact-to-f64 conversion would round or become non-finite. - Specialize D4 exact determinants and keep determinant/error-bound zero coefficients from evaluating overflowing absent terms. - Update exact benchmark comparison reporting to compare strict and rounded APIs against legacy v0.4.2 rows. - Archive release performance reports [`2817d01`](https://github.com/acgetchell/la-stack/commit/2817d01374ad0aeab98d6f48a3dae9b30f878a8a) + - Add an archive-performance utility that promotes curated benchmark reports into docs/PERFORMANCE.md while archiving prior release comparisons - Generate release comparisons in isolated temporary worktrees, including legacy command fallback for published tags - Wire release and historical archive recipes into just, Python packaging, and release documentation - Automate published performance report archiving [`d31e26a`](https://github.com/acgetchell/la-stack/commit/d31e26a9d7a47a6c3089028630640bcff5afe7c0) + - Track the latest curated release comparison in docs/PERFORMANCE.md and archive older comparisons under docs/archive/performance/ - Let performance-archive-published discover the latest stable GitHub release and previous stable baseline automatically - Generate release comparisons in isolated temporary worktrees, with release-asset restore and local baseline fallback paths - Update benchmark and release docs to use the scripted workflow instead of manual checkout steps - Split local and release performance comparisons [`7258525`](https://github.com/acgetchell/la-stack/commit/7258525590f2ed68d41879e71c833010e408e7f7) + - Add default performance-local and performance-release workflows that infer the relevant release tags and run in temporary worktrees. - Add a performance-github-assets workflow for comparing stored GitHub Actions release benchmark assets without local cargo runs. - Normalize release tags before fetching, downloading assets, or checking out detached worktrees. - Update performance docs, release guidance, and generated report instructions to use the new benchmark workflows. - Add vs_linalg-only performance checks [`d7c1487`](https://github.com/acgetchell/la-stack/commit/d7c1487115e1a8e5bb1ec4fcc7592786e300e2ce) + - Add local workflows for comparing current non-exact la-stack kernels against a release baseline without rerunning current nalgebra/faer or exact benchmarks. - Route archive-performance baseline and current benchmark commands by suite, with legacy fallback support for older release worktrees. - Document the faster release-signal workflow and expand Semgrep fixtures for benchmark, example, doctest, and public panic-path rules. @@ -517,11 +630,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extract determinant scale exponent calculation into a private helper - Assert typed DeterminantScaleOverflow errors for dimension conversion and exponent product overflow - Harden support script parsing [`87e1d00`](https://github.com/acgetchell/la-stack/commit/87e1d0042ad2de7888c1a065ab78524a63f4c045) + - Require Python 3.13 for support-script tooling and align Ruff/Ty with that baseline. - Replace mypy with strict Ty checking in the Python workflow. - Parse TOML, JSON, argparse, and Semgrep inputs into typed boundary objects before downstream use. - Reject malformed Criterion estimates, non-finite timings, invalid confidence intervals, and malformed Semgrep result shapes. - Harden Rust release hygiene [`8e12c93`](https://github.com/acgetchell/la-stack/commit/8e12c935fe54e265e8ceb640702267ec0e71b7b1) + - Promote missing documentation and dead code lints to deny-level checks. - Forbid unsafe code explicitly across Rust modules and benchmark targets. - Document the LU/LDLT empty-matrix convention for D=0. @@ -549,6 +664,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Return matrix-cell metadata when inf-norm row sums or symmetry tolerance scaling overflow. - Avoid reparsing finite-by-construction RHS vectors in LU and LDLT solves. - Re-raise unexpected archive failures [`7938386`](https://github.com/acgetchell/la-stack/commit/7938386166f1f3f5cf594c5def67458d48e19a98) + - Limit archive-performance CLI error handling to expected validation, filesystem, subprocess, and runtime failures. - Let unexpected exceptions propagate so benchmark archiving bugs surface during development. @@ -560,6 +676,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fuse multiplier computation with trailing updates for larger dimensions to reduce extra column walks - Rely on the LDLT factorization proof instead of a redundant final finite-storage scan - Optimize exact and factorized solve kernels [`1690355`](https://github.com/acgetchell/la-stack/commit/1690355bf27c2cbba685ba0cd70486275c7620b8) + - Split LU and LDLT solve paths so tiny matrices keep the direct kernels while larger fixed dimensions avoid extra substitution work. - Convert dyadic exact solve results directly to finite f64 and preserve UnrepresentableReason recovery semantics on strict conversion failures. - Modernize release branch commands and keep just recipes sorted. @@ -593,11 +710,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [`758321a`](https://github.com/acgetchell/la-stack/commit/758321acf872b1f17286ff3bb7bee6a807e4b440) - Encode nonzero mantissas in exact decomposition [`7a664ed`](https://github.com/acgetchell/la-stack/commit/7a664ede2f4add168c5813f8d24e16732fa03b30) - - Replace the exact-arithmetic zero mantissa sentinel with `Option<NonZeroU64>`. + - Replace the exact-arithmetic zero mantissa sentinel with `Option<NonZeroU64>`. - Carry nonzero mantissa proof through matrix/vector decomposition and BigInt scaling. - Clarify determinant documentation around uncertified `det()` bounds. - Keep SPD determinant proptests on the tolerance-aware LU path. - Simplify finite proof wrappers [`54b603c`](https://github.com/acgetchell/la-stack/commit/54b603c21f0eb5b4d63ff334fac7f8cc325ebc2e) + - Use the checked proof-wrapper constructors as the single internal path for finite matrices and vectors. - Remove exact-arithmetic tests that duplicated the matrix and vector non-finite boundary checks. @@ -623,10 +741,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add a roadmap covering the v0.4.x stable-Rust issue sequence and the v0.5.0 generic_const_exprs anchor. - Refresh generated changelog entries and archived changelog grouping. - Document finite RHS solve validation [`075aed7`](https://github.com/acgetchell/la-stack/commit/075aed78cf8264fc920258f1f1d977ddd589ffd7) + - Document that LU and LDLT solve_vec reject non-finite RHS entries with LaError::NonFinite metadata. - Cite the Bareiss reference in the exact solve helper docs and describe exact-arithmetic growth and complexity. - Cover finite proof defaults and non-finite RHS solve boundaries in unit tests. - Clarify finite solve and norm guarantees [`fb71485`](https://github.com/acgetchell/la-stack/commit/fb71485cac0b464b2fa9ee949140a558b7738781) + - State that LU and LDLT solve_vec use floating-point substitution without a certified absolute rounding-error bound. - Clarify that inf_norm reports NonFinite for unchecked stored NaN/โˆž as well as row-sum overflow. - Exercise the unchecked finite-proof fixture path directly in exact tests. @@ -644,6 +764,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tolerance overflow. - Update exact examples to propagate typed crate errors instead of unwrapping. - Harden Semgrep fixture parsing [`ac44c07`](https://github.com/acgetchell/la-stack/commit/ac44c078cc4435d5beca27f1890fbb4046cf5952) + - Ignore non-canonical todoruleid annotations when counting expected rule hits. - Reject malformed Semgrep JSON results with clear stderr diagnostics instead of propagating KeyError. - Revalidate finite proof conversions [`419a90f`](https://github.com/acgetchell/la-stack/commit/419a90f7267608051736498154ac5e6faf0909c5) @@ -655,6 +776,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tighten Codecov status thresholds and extend benchmark workflow timeout. - Keep Semgrep constructor fixtures aligned with public API guardrails. - Revalidate public compute inputs [`ffca00e`](https://github.com/acgetchell/la-stack/commit/ffca00e9dd6fde5e57c8064f69807a76e45a469e) + - Parse Matrix and Vector storage into private finite proof-bearing types at public compute boundaries. - Reject unchecked non-finite storage before LU, LDLT, determinant, norm, dot, and exact-arithmetic paths can proceed. - Keep unchecked proof-wrapper constructors crate-private for internal paths with local finiteness proofs. @@ -671,6 +793,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add archive-aware changelog generation, post-processing, and tag-release tooling. - Preserve exact arithmetic overflow reporting without non-finite sentinel defaults. - Modernize tooling checks and example execution [`da626bc`](https://github.com/acgetchell/la-stack/commit/da626bcca899aa91d58f728db433a53a46179e92) + - Run examples from prebuilt binaries instead of invoking cargo run for each example. - Add check/fix recipe aliases and guard documented command ordering with Semgrep. - Document the Rust-native Markdown, YAML, TOML, spelling, and workflow action policy. @@ -791,7 +914,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 pass that decomposes each entry and return `Err(LaError::NonFinite { row, col })` on the first non-finite cell. - `bareiss_det_int`, `bareiss_det`, `gauss_solve` now return - `Result<_, LaError>`; error propagates to every public entry point via + `Result<_, LaError>`; error propagates to every public entry point via `?`. - `validate_finite` and `validate_finite_vec` removed (dead after the refactor); `det_sign_exact` relies on IEEE 754 NaN/โˆž propagation @@ -908,7 +1031,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 off-diagonal pairs as an asymmetry. This prevents cases where a single infinite entry paired with a finite entry would incorrectly pass as symmetric because the matrix's infinite norm blew the tolerance up to - infinity, making the comparison `diff > eps` return false. + infinity, making the comparison `diff > eps` return false. ### Maintenance @@ -1119,6 +1242,7 @@ Older releases are archived by minor series: - [0.2.x](docs/archives/changelog/0.2.md) - [0.1.x](docs/archives/changelog/0.1.md) +[Unreleased]: https://github.com/acgetchell/la-stack/compare/v0.4.6...HEAD [0.4.6]: https://github.com/acgetchell/la-stack/compare/v0.4.5...v0.4.6 [0.4.5]: https://github.com/acgetchell/la-stack/compare/v0.4.4...v0.4.5 [0.4.4]: https://github.com/acgetchell/la-stack/compare/v0.4.3...v0.4.4 diff --git a/cliff.toml b/cliff.toml index 9f8f6ce..c94b4a6 100644 --- a/cliff.toml +++ b/cliff.toml @@ -1,8 +1,7 @@ -# git-cliff configuration for la-stack +# Shared git-cliff configuration # https://git-cliff.org/docs/configuration -# Consumer-owned exception to the packaged policy: preserve historical -# chore(deps-dev) bodies and their upstream release-note/changelog links. -# See scripts/README.md for the migration boundary. +# Materialized from research-repo-tools 0.1.7; only the deps-dev body condition +# below differs from the packaged template. See scripts/README.md. [changelog] # Keep a Changelog header @@ -16,10 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Tera template for each release section. # -# Release sections are generated from commit ranges. Angle brackets in commit -# text are escaped so Rust generics like Foo<T> do not render as HTML tags on -# GitHub. Breaking footers are separate from commit.body; render their full -# descriptions in the release summary before grouping ordinary entries. +# Release sections are generated from commit ranges. The normalizer escapes +# prose angle brackets after protecting Markdown code, so Rust generics remain +# literal inside code spans and fences. Breaking footers are separate from +# commit.body; render their full descriptions before grouping ordinary entries. body = """ {% if version -%} ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} @@ -41,13 +40,13 @@ body = """ ### {{ group | upper_first }} {% for commit in commits %} - {% if commit.breaking %}[**breaking**] {% endif %}\ - {{ commit.message | split(pat="\n") | first | upper_first | trim | replace(from="<", to="<") | replace(from=">", to=">") }} \ + {{ commit.message | split(pat="\n") | first | upper_first | trim }} \ [`{{ commit.id | truncate(length=7, end="") }}`]\ (https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ /commit/{{ commit.id }}) - {%- if commit.body and group != "Dependencies" %} + {%- if commit.body and (group != "Dependencies" or commit.scope == "deps-dev") %} - {{ commit.body | replace(from="<", to="<") | replace(from=">", to=">") | indent(prefix=" ", first=true) }} + {{ commit.body | indent(prefix=" ", first=true) }} {%- endif %} {%- endfor %} {% endfor %}\n @@ -68,8 +67,13 @@ footer = """ /tree/{{ release.version }} {% endif -%} {% else -%} - [Unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ - /compare/{{ release.previous.version }}...HEAD + {% if release.previous.version -%} + [Unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ + /compare/{{ release.previous.version }}...HEAD + {% else -%} + [Unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ + /commits/HEAD + {% endif -%} {% endif -%} {% endfor -%} """ @@ -88,7 +92,8 @@ conventional_commits = true filter_unconventional = false filter_commits = false protect_breaking_commits = true -tag_pattern = "v[0-9]*" +# Expanded from the package's shared SemVer grammar when this template is read. +tag_pattern = '\Av(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:(?:0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z' topo_order = false sort_commits = "oldest" @@ -109,7 +114,7 @@ commit_parsers = [ # Dependency bumps: keep all bump commits so archived release history # preserves CI/action/tooling dependency updates as well as Rust crates. - { message = "^chore\\(deps\\): bump ", group = "Dependencies" }, + { message = "^chore\\(deps(?:-[^)]+)?\\)!?: bump ", group = "Dependencies" }, { message = "(?i)^bump\\s+(deps?\\b|dependencies\\b|[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+|[A-Za-z0-9_.-]+\\s+from\\s+)", group = "Dependencies" }, # Conventional commit types @@ -122,7 +127,7 @@ commit_parsers = [ { message = "^style", group = "Changed" }, { message = "^build", group = "Maintenance" }, - # Non-conventional patterns used in la-stack history + # Historical non-conventional commit categories { message = "(?i)^add", group = "Added" }, { message = "(?i)^support", group = "Added" }, { message = "(?i)^implement", group = "Added" }, diff --git a/docs/archives/changelog/0.3.md b/docs/archives/changelog/0.3.md index 5badd2c..d8fb1f3 100644 --- a/docs/archives/changelog/0.3.md +++ b/docs/archives/changelog/0.3.md @@ -35,7 +35,7 @@ - Add `solve_exact()` returning `[BigRational; D]` via Gaussian elimination with partial pivoting in BigRational - - Add `solve_exact_f64()` converting exact result to `Vector<D>` + - Add `solve_exact_f64()` converting exact result to `Vector<D>` - Add `validate_finite_vec()` helper for vector input validation - Add `exact_solve_3x3` example (near-singular system: f64 vs exact) - Macro-generate solve tests for D=2..5 diff --git a/pyproject.toml b/pyproject.toml index 59afb79..ed58908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,7 +167,7 @@ schema = 1 owner = "acgetchell" repository = "la-stack" formatter = "changelog-rumdl.toml" -# Historical deps-dev bodies contain release-note links that the packaged policy omits. +# Packaged 0.1.7 template plus one deps-dev body-preservation exception. cliff-config = "cliff.toml" [tool.research-repo-tools.release] diff --git a/scripts/README.md b/scripts/README.md index 8816a7d..304396c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -309,11 +309,15 @@ Preview validates root and archive candidates without publishing them. Malformed versions/dates, duplicate releases, conflicting retained notes, and formatter failures stop publication with diagnostics. -`cliff.toml` remains a deliberate consumer policy exception: the packaged -policy newly groups historical `chore(deps-dev)` entries as Dependencies and -omits their authored bodies. Those bodies contain retained Ruff, Ty, and -setuptools release-note, changelog, and comparison links. Retaining the existing -policy preserves this history and its original code-span representation. +`cliff.toml` is materialized from the installed package's common 0.1.7 template. +It adopts the shared categories, SemVer tag grammar, compare links, and +Markdown-aware code handling. Its only semantic exception retains bodies for +`deps-dev` commits when grouped as Dependencies, preserving historical Ruff, +Ty, and setuptools release-note, changelog, and comparison links. A focused +integration check compares the parsed configuration with the installed template +plus that single condition, preventing unrelated local policy drift. +The exception can retire after adopting a published shared solution to +[research-repo-tools#62](https://github.com/acgetchell/research-repo-tools/issues/62). The shared normalizer preserves complete breaking-change descriptions, Markdown links, and literal code in the input while adding merged-PR summaries. It retains embedded conventional headings that the old normalizer deduplicated. diff --git a/scripts/tests/test_changelog_integration.py b/scripts/tests/test_changelog_integration.py index bf292fe..daad9bc 100644 --- a/scripts/tests/test_changelog_integration.py +++ b/scripts/tests/test_changelog_integration.py @@ -66,6 +66,20 @@ def test_published_package_is_exactly_pinned_and_included_in_dev() -> None: assert package["source"] == {"registry": "https://pypi.org/simple"} +def test_consumer_template_has_only_the_documented_shared_policy_exception() -> None: + """Keep the installed common format, with one bounded historical-body exception.""" + shared = run_safe_command( + "uv", + ["run", "--locked", "--group", "dev", "research-repo-tools", "templates", "cliff.toml", "--owner", "acgetchell", "--repository", "la-stack"], + cwd=REPO_ROOT, + ).stdout + expected = shared.replace( + '{%- if commit.body and group != "Dependencies" %}', + '{%- if commit.body and (group != "Dependencies" or commit.scope == "deps-dev") %}', + ) + assert tomllib.loads((REPO_ROOT / "cliff.toml").read_text(encoding="utf-8")) == tomllib.loads(expected) + + def test_archive_rotation_preserves_notes_references_and_relative_links(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: """Root and archived notes remain discoverable after a minor-series rotation.""" changelog = consumer / "CHANGELOG.md" @@ -115,7 +129,13 @@ def test_generation_uses_consumer_policy_dates_and_transactional_preview(consume consumer, "chore(deps-dev): bump ruff from 0.16.1 to 0.16.2\n\nRead [release notes](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2).", ) - commit(consumer, "fix!: preserve matrix invariants (#42)\n\nBREAKING CHANGE: preserve exact errors and migration instructions.") + commit( + consumer, + "fix!: preserve `Vector<D>` invariants (#42)\n\n" + "Keep `Matrix<T>` and literal `<D>`.\n\n" + "```rust\nfn value<T>() -> Vector<T> { todo!() }\n```\n\n" + "BREAKING CHANGE: preserve exact errors and migration instructions.", + ) (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.5] - 2000-01-02\n\n- Authored date.\n", encoding="utf-8") metadata_before = {name: (consumer / name).read_bytes() for name in ("Cargo.toml", "pyproject.toml")} before = markdown_bytes(consumer) @@ -125,6 +145,10 @@ def test_generation_uses_consumer_policy_dates_and_transactional_preview(consume assert "https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2" in preview assert "https://github.com/acgetchell/la-stack/pull/42" in preview assert "preserve exact errors and migration instructions" in preview + assert "`Vector<D>`" in preview + assert "`Matrix<T>`" in preview + assert "`<D>`" in preview + assert "fn value<T>() -> Vector<T>" in preview assert markdown_bytes(consumer) == before assert not (consumer / "docs/archives/changelog").exists() assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30") == 0