diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..7c51009 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,3 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5e8ba4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +.git_archival.txt export-subst +tests/_generate/pinned/** -text diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9b32546 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @gaborbernat diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..8090cea --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Contributing + +Add a `docs/changelog.d/+short-name..rst` fragment for a change that affects package users. Choose a unique short +name; the release workflow links the fragment to its merged squash pull request and credits the human author. A pull +request number is not needed in the filename. + +Use `feature` for Added, `change` for Changes, `bugfix` for Fixed, and `doc` for Documentation. Use `breaking`, +`security`, `performance`, `deprecation`, `removal`, or `packaging` only when the change affects users in that way. A +supported Python version added or removed is a `feature` and results in a minor release. + +Use `skip news` for internal maintenance without a user-facing change. The label may be added after opening the pull +request. Dependabot and maintenance bot pull requests without a fragment receive it automatically; a bot pull request +with a fragment still needs the news check to pass. For a change written by more than one person, put all human authors +in a `by` credit. Do not list bots or reviewers. + +The documentation preview shows fragments under Unreleased. The versioned `docs/changelog.rst` changes only during +release preparation. The release workflow chooses a version from the fragment types, runs `prek` on the assembled +changelog, and publishes GitHub notes from the tagged changelog after PyPI succeeds. diff --git a/.github/FUNDING.yaml b/.github/FUNDING.yaml new file mode 100644 index 0000000..220f93d --- /dev/null +++ b/.github/FUNDING.yaml @@ -0,0 +1,2 @@ +github: gaborbernat +thanks_dev: u/gh/gaborbernat diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..90a06ec --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,46 @@ +# Security policy + +python-sbom writes a CycloneDX SBOM into wheels, from a hatchling build hook or from a command that rewrites a wheel +another build backend built, and renders or validates that SBOM. This policy says how to report a security problem and +how we decide whether a report is one. + +## Reporting a vulnerability + +**Do not open a public issue for a security problem.** Use +[GitHub's private vulnerability reporting](https://github.com/tox-dev/python-sbom/security/advisories/new). If neither +works for you, email [gaborjbernat@gmail.com](mailto:gaborjbernat@gmail.com). + +Include what the attacker controls and what they gain, the steps or a proof of concept, and the versions and platform +you tested. Write the report yourself; unreviewed tool or model output takes longer to triage. + +## What to expect + +Volunteers maintain python-sbom, with no on-call rotation. We aim to acknowledge a report within **5 working days** and +assess it within **15 working days**. Fixes are developed in a private fork attached to the draft advisory, ship in a +new release, and the advisory, with a CVE requested through GitHub, goes out with that release. The default embargo is +**90 days** from the report; we agree any change with the reporter. We credit reporters in the advisory under the name +they choose, or not at all. There is no bug bounty. + +## Supported versions + +Only the latest release receives fixes. Confirm the problem reproduces on it before reporting. + +## Vulnerability or bug + +A report is a vulnerability when an attacker who controls only an input that the [threat model](THREAT_MODEL.md) lists +as untrusted breaks a guarantee python-sbom makes. A report that first needs control of something the threat model +trusts, such as the project source, its `pyproject.toml` or the build environment, is a bug: we fix it in the open and +publish no advisory. Classify by who supplies the dangerous input, not by how bad the outcome sounds. + +**In scope** + +- A bundled wheel, or an SBOM given to `python-sbom spdx` or `validate`, that makes python-sbom write outside its output + file or the wheel it rewrites, or run code. +- An SBOM that misstates what a wheel holds in a way its inputs cannot explain, such as a wrong hash. + +**Out of scope** + +- Anything hatchling's own build trusts. +- Vulnerabilities in the packages an SBOM lists; report those to their projects. + +If you cannot tell which side a finding falls on, report it privately and we will decide. diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md new file mode 100644 index 0000000..a50cc46 --- /dev/null +++ b/.github/THREAT_MODEL.md @@ -0,0 +1,78 @@ +# Threat model + +This model says what python-sbom trusts, where untrusted input enters, and which guarantees hold at each boundary, so a +report can be classified as a vulnerability or a bug. [SECURITY.md](SECURITY.md) covers reporting; if the two disagree, +SECURITY.md wins and we fix this file. + +## How python-sbom works + +The build hook runs inside a hatchling wheel build. It reads the wheel's core metadata, the project's license files, the +wheels the project bundles and the installed distributions of the build environment, and writes a CycloneDX document +that it writes into the built wheel. `python-sbom embed` does the same for a wheel another backend built, +`python-sbom archive` describes another zip archive such as a zipapp, and `python-sbom spdx` and `validate` read +CycloneDX documents and write or check them. + +```mermaid +flowchart LR + project[("Project source, pyproject.toml
(trusted)")] --> hook["python-sbom"] + env[("Build environment
(trusted)")] --> hook + bundled[("Bundled wheels
(third-party content)")] -->|"metadata, RECORD, vendor list"| hook + sbom[("SBOM files given to spdx, validate")] -->|"untrusted JSON"| hook + hook --> wheel[("Wheel")] + classDef trusted fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; + classDef boundary fill:#ffe3a3,stroke:#d29200,color:#3a2c00; + class project,env,hook,wheel trusted; + class bundled,sbom boundary; +``` + +## Trusted + +An attacker who controls any of these already has what python-sbom could give them: + +- The project source, its `pyproject.toml`, the build hook script, and the module the `content-provider` option or + `--content-provider` names: python-sbom imports that module from the project directory, so `embed` and `archive` run + code from `--root`. +- The git checkout that tracks the project, whose `HEAD` python-sbom reads. A repository that merely contains the + project directory is not trusted: git is used only when it tracks the project, and runs with `core.fsmonitor` off. +- The build environment and the build backend. +- The wheel handed to `python-sbom embed` and the archive handed to `python-sbom archive`, which the project's own build + produced. + +## Untrusted inputs + +| Input | Comes from | What python-sbom guarantees | +| --------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Metadata and `RECORD` of bundled wheels | Upstream projects | Read as text with capped sizes, copied as JSON strings; every member is checked against its `RECORD` row, duplicate members are refused, nothing is executed. | +| `_vendor/vendor.txt` of bundled wheels | Upstream projects | Each line must pin one exact version, or the build fails. | +| SBOM files given to `spdx`, `validate` | Whoever produced the file | Parsed as JSON only, with capped sizes and nesting; problems are reported, not raised; `spdx` writes the one file named on the command line. | + +## Threats and controls + +| STRIDE | Threat | Control | +| ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------- | +| Tampering | A bundled wheel's metadata injects JSON into the document. | The document is serialized with `json.dumps`. | +| Tampering | A bundled wheel changes and the SBOM still lists it. | Each bundled wheel is hashed from its bytes at build time. | +| Elevation of privilege | A bundled wheel runs code during the build. | Wheels are read as zip archives; nothing in them is imported. | + +## Release chain + +Releases run on the shared [tox-dev/github-workflows](https://github.com/tox-dev/github-workflows) pipeline. A release +App, not a person, pushes the release commit and tag; the build installs its tools from a hash-checked `pylock.toml` +resolved with a seven-day upload cooldown by default; PyPI receives the files through +[trusted publishing](https://docs.pypi.org/trusted-publishers/) with attestations, and the GitHub Release is immutable. +Every workflow pins third-party actions to a commit SHA. + +## Severity + +- **High:** a bundled wheel or SBOM file that makes python-sbom run code or write outside its output. +- **Medium:** an SBOM that misstates a hash, a license or the contents of a wheel. +- **Low:** a crash on a malformed bundled wheel or SBOM file instead of a clean error. + +## Accepted risks + +- The SBOM reports what the build environment holds; it does not check that environment against a lock. + +## Keeping this current + +Revisit this model when python-sbom gains a new input, a new way to run code, or a new place it writes to, and whenever +an advisory is published. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..3c1c011 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,30 @@ +version: 2 +multi-ecosystem-groups: + dependencies: + schedule: + interval: weekly + day: tuesday + labels: + - dependencies + - skip news +updates: + - package-ecosystem: github-actions + directory: / + patterns: + - '*' + multi-ecosystem-group: dependencies + labels: + - dependencies + - skip news + cooldown: + default-days: 7 + - package-ecosystem: pre-commit + directory: / + patterns: + - '*' + multi-ecosystem-group: dependencies + labels: + - dependencies + - skip news + cooldown: + default-days: 7 diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..5f89818 --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot[bot] + - pre-commit-ci[bot] diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..ad01f2c --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,62 @@ +name: ๐Ÿงช check +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: "27 8 * * *" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + test: + name: ๐Ÿงช test + if: github.event_name != 'schedule' || github.repository_owner == 'gaborbernat' + uses: tox-dev/github-workflows/.github/workflows/check.yaml@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + permissions: + contents: read + with: + include: | + [ + {"env": "3.15", "python": "3.15"}, + {"env": "3.14", "python": "3.14"}, + {"env": "3.13", "python": "3.13"}, + {"env": "3.12", "python": "3.12"}, + {"env": "3.11", "python": "3.11"}, + {"env": "3.15", "python": "3.15", "os": "windows-2025"}, + {"env": "3.11", "python": "3.11", "os": "windows-2025"}, + {"env": "3.15", "python": "3.15", "os": "macos-26"}, + {"env": "dev"}, + {"env": "docs"}, + {"env": "fix"}, + {"env": "type"}, + {"env": "pkg_meta"} + ] + release: + name: ๐Ÿท๏ธ Release dry run + uses: $/.github/workflows/prepare-release.yaml + permissions: + contents: read + pull-requests: read + with: + dry-run: true + hooks: + name: ๐Ÿงน Hooks under the cooldown + uses: tox-dev/github-workflows/.github/workflows/cooled-hooks.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + gate: + name: ๐Ÿšฆ ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Non-PR checks pass' }} + if: ${{ !cancelled() }} # codespell:ignore cancelled + needs: [test, release, hooks] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - name: ๐Ÿšฆ Require all needed jobs to pass + uses: tox-dev/github-workflows/required-checks@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + with: + needs: ${{ toJSON(needs) }} diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..cec793a --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,22 @@ +name: ๐Ÿ›ก๏ธ CodeQL +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + schedule: + - cron: "35 8 * * 2" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: {} +jobs: + analyze: + name: ๐Ÿ›ก๏ธ Analyze + uses: tox-dev/github-workflows/.github/workflows/codeql.yaml@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + permissions: + contents: read + security-events: write + with: + languages: '["actions","python"]' + paths-ignore: '["tests"]' diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml new file mode 100644 index 0000000..d5a8c28 --- /dev/null +++ b/.github/workflows/news.yaml @@ -0,0 +1,30 @@ +name: Changelog +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] +permissions: + contents: read + pull-requests: read +jobs: + changelog: + name: ๐Ÿ“ News fragment + runs-on: ubuntu-26.04 + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: ๐Ÿ Install uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - name: ๐Ÿ“ฆ Install locked release tools + uses: tox-dev/github-workflows/install-locked-tools@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + with: + groups: release-news + venv: ${{ runner.temp }}/release-tools + - name: ๐Ÿ” Validate changelog fragments + uses: tox-dev/github-workflows/validate-news@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000..edfa8aa --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -0,0 +1,34 @@ +name: Prepare release +on: + workflow_dispatch: + inputs: + bump: + description: Version bump strategy + required: true + type: choice + options: + - auto + - major + - minor + - patch + default: auto + workflow_call: + inputs: + dry-run: + description: Assemble the release without pushing, for the pull request gate + type: boolean + required: true +permissions: {} +jobs: + prepare: + name: ๐Ÿท๏ธ Prepare Towncrier release + uses: tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + pull-requests: read + with: + bump: ${{ inputs.bump || 'auto' }} + dry-run: ${{ inputs.dry-run == true }} + release-auth-deployment: false + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..c11f6c9 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,56 @@ +name: Release to PyPI +on: + push: + tags: ["*"] +permissions: + contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + build: + name: ๐Ÿ—๏ธ Build + uses: tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + permissions: + contents: read + notes: + name: ๐Ÿ“ Render GitHub Release notes + uses: tox-dev/github-workflows/.github/workflows/release-notes.yaml@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + permissions: + contents: read + with: + tag: ${{ github.ref_name }} + changelog: docs/changelog.rst + artifact-url: https://pypi.org/project/python-sbom/${{ github.ref_name }}/ + publish: + name: ๐Ÿš€ Publish to PyPI + needs: [build, notes] + runs-on: ubuntu-26.04 + environment: + name: release + url: https://pypi.org/project/python-sbom/${{ github.ref_name }} + permissions: + id-token: write + steps: + - name: ๐Ÿ“ฉ Download distributions + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }} + path: dist/ + - name: ๐Ÿš€ Publish to PyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + attestations: true + packages-dir: dist + release: + name: ๐Ÿš€ Publish GitHub Release + needs: [build, publish, notes] + uses: tox-dev/github-workflows/.github/workflows/github-release.yaml@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + permissions: + contents: write + with: + project: python-sbom + version: ${{ github.ref_name }} + tag: ${{ github.ref_name }} + artifact-pattern: python-package-distributions + notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml new file mode 100644 index 0000000..eafe9e6 --- /dev/null +++ b/.github/workflows/scorecard.yaml @@ -0,0 +1,26 @@ +name: ๐Ÿ›ก๏ธ scorecard +on: + branch_protection_rule: + schedule: + - cron: "43 8 * * 2" + push: + branches: ["main"] + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: {} +jobs: + analysis: + name: ๐Ÿ›ก๏ธ Scorecard analysis + if: github.repository == 'tox-dev/python-sbom' + permissions: + actions: read # read workflow runs for the Packaging check + checks: read # read check runs for the CI-Tests and SAST checks + contents: read # check out and scan the repository + id-token: write # authenticate the results published to the scorecard API + issues: read # read issue activity for the Maintained check + pull-requests: read # read reviews for the Code-Review check + security-events: write # upload the SARIF results to code scanning + statuses: read # read commit statuses for the CI-Tests check + uses: tox-dev/github-workflows/.github/workflows/scorecard.yaml@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml new file mode 100644 index 0000000..a4aabe6 --- /dev/null +++ b/.github/workflows/upgrade-release-tools.yaml @@ -0,0 +1,17 @@ +name: Upgrade release tools +on: + workflow_dispatch: + schedule: + - cron: "51 8 * * 2" +permissions: {} +concurrency: + group: upgrade-release-tools + cancel-in-progress: false +jobs: + upgrade: + name: ๐Ÿ”€ Upgrade release tools + uses: tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@e1361e2efa1e9dd9705bb9be719d923cd9154707 # 6.7.1 + permissions: + contents: read + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index 52482c9..1e8968b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,6 @@ -/magic -.idea +*.pyc *.egg-info +dist/ .tox/ -/.venv -.coverage* -coverage.xml -.*_cache -__pycache__ -**.pyc -/build -dist -src/hatch_pep770/version.py +/src/python_sbom/version.py /uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6969ab9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,86 @@ +# prek runs lower priorities first and equal ones concurrently, so hooks sharing a priority never write the same +# file; pre-commit ignores the key and runs hooks in file order. +ci: + autoupdate_schedule: quarterly +default_language_version: + python: python3.15 +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: end-of-file-fixer + priority: 0 + - id: trailing-whitespace + priority: 1 + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: a76b92851fc9eede9f9bc4073eb55202f34eb0df # frozen: 0.39.0 + hooks: + - id: check-github-workflows + priority: 6 + args: ["--verbose"] + - repo: https://github.com/codespell-project/codespell + rev: 57b21406f092110c18776e39b0bda50d37c945c8 # frozen: v2.4.3 + hooks: + - id: codespell + priority: 6 + - repo: https://github.com/tox-dev/tox-toml-fmt + rev: "5125f601c3e134347288c3d42f3b34298b7cf1be" # frozen: v1.11.1 + hooks: + - id: tox-toml-fmt + priority: 2 + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "50e0f3aee9dc5747a5f173f79a7417403c18dda6" # frozen: v2.30.3 + hooks: + - id: pyproject-fmt + priority: 2 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3 + hooks: + - id: ruff-check + priority: 3 + args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] + - repo: https://github.com/gaborbernat/simplint-pre-commit + rev: "c8719dca99c13381570bc69f8955c9448fc271b7" # frozen: v0.7.1 + hooks: + - id: simplint + priority: 4 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3 + hooks: + - id: ruff-format + priority: 5 + - repo: https://github.com/google/yamlfmt + rev: "21ca5323a9c87ee37a434e0ca908efc0a89daa07" # frozen: v0.21.0 + hooks: + - id: yamlfmt + priority: 2 + - repo: https://github.com/executablebooks/mdformat + rev: 82912cdaea4fb830f751504486a7879c70526547 # frozen: 1.0.0 + hooks: + - id: mdformat + priority: 2 + args: ["--wrap", "120"] + additional_dependencies: + - mdformat-gfm==1.0.0 + - mdformat-ruff==0.1.3 + - repo: https://github.com/LilSpazJoekp/docstrfmt + rev: c1813841673cdcd6cf602dde1edb78a5d5e45235 # frozen: v2.2.1 + hooks: + - id: docstrfmt + priority: 2 + files: ^docs/(changelog|contributing)[.]rst$ + args: ["-l", "120", "--preserve-adornments"] + additional_dependencies: + - "sphinx==9.2.0" + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # frozen: v1.31.1 + hooks: + - id: zizmor + priority: 6 + args: ["--min-severity", "high"] + - repo: meta + hooks: + - id: check-hooks-apply + priority: 6 + - id: check-useless-excludes + priority: 6 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..6f130a0 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,8 @@ +version: 2 +build: + os: ubuntu-lts-latest + tools: {} + commands: + - curl -LsSf https://astral.sh/uv/install.sh | sh + - ~/.local/bin/uv tool install tox --with tox-uv -p 3.15 --managed-python + - ~/.local/bin/tox run -e docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9739d57 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +## Tests + +Add a test for every new feature and bug fix. A change without a test needs a reason in the pull request description. diff --git a/README.md b/README.md index ed8faf6..3d316ce 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,51 @@ -# hatch-pep770 +# python-sbom -A [hatchling](https://hatch.pypa.io/latest/) build hook that writes a [CycloneDX](https://cyclonedx.org/) SBOM into -every wheel, where [PEP 770](https://peps.python.org/pep-0770/) says installers and scanners look for it. +[![PyPI](https://img.shields.io/pypi/v/python-sbom?style=flat-square)](https://pypi.org/project/python-sbom/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/python-sbom.svg)](https://pypi.org/project/python-sbom/) +[![check](https://github.com/tox-dev/python-sbom/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/python-sbom/actions/workflows/check.yaml) +[![Documentation Status](https://readthedocs.org/projects/python-sbom/badge/?version=latest)](https://python-sbom.readthedocs.io/en/latest/?badge=latest) +[![Downloads](https://static.pepy.tech/badge/python-sbom/month)](https://pepy.tech/project/python-sbom) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/tox-dev/python-sbom/badge)](https://scorecard.dev/viewer/?uri=github.com/tox-dev/python-sbom) + +SBOMs for Python distributions. python-sbom writes a [CycloneDX](https://cyclonedx.org/) SBOM into every wheel you +build, where [PEP 770](https://peps.python.org/pep-0770/) tells installers to keep it. The SBOM records your project, +the runtime dependencies it declares, any wheels it bundles, and the build toolchain, and a rebuild with the same inputs +reproduces it byte for byte. + +## Install + +A hatchling project names `python-sbom[hatch]` in its build requirements, as shown below. To embed an SBOM into a wheel +another backend built, install the command line into the environment that built the wheel, since the SBOM lists that +environment's packages as the build tools (or point `--build-env` at it): + +```console +python -m pip install python-sbom +``` + +To convert and validate SBOMs in CI, a separate tool environment is fine: + +```console +pipx install 'python-sbom[validate]' +``` + +## Use + +With [hatchling](https://hatch.pypa.io/latest/), add the hook to the build requirements and enable it: + +```toml +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling>=1.28", "python-sbom[hatch]>=0.1"] + +[tool.hatch.build.hooks.python-sbom] +``` + +With another build backend, such as meson-python, embed the SBOM after the build: + +```console +python-sbom embed dist/*.whl +``` + +Describe another zip archive the project publishes, such as a zipapp, with `python-sbom archive`. The +[documentation](https://python-sbom.readthedocs.io) covers the options, adding project knowledge through a content +provider, rendering an SBOM as SPDX and validating it. diff --git a/bench/__init__.py b/bench/__init__.py new file mode 100644 index 0000000..c03b83b --- /dev/null +++ b/bench/__init__.py @@ -0,0 +1 @@ +"""Benchmarks of python-sbom's hot paths, timed with pyperf; run them with ``python -m bench``.""" diff --git a/bench/__main__.py b/bench/__main__.py new file mode 100644 index 0000000..6344f94 --- /dev/null +++ b/bench/__main__.py @@ -0,0 +1,324 @@ +""" +CLI entry point: ``python -m bench ``, run from the project root or with the root on ``PYTHONPATH``. + +``prepare`` + build the fixtures into the work directory; the only command that needs the network +``run [CASE ...] [-- PYPERF-OPTION ...]`` + time each case (all by default) in its own pyperf process tree, one JSON file per case under + ``//``; options after ``--`` reach pyperf, overriding the case's defaults (e.g. ``--rigorous``) +``profile [CASE ...]`` + run each case once under cProfile, after one untimed warm-up, and write its stats, the top functions by + cumulative and own time, and with ``--py-spy`` a speedscope flame graph +``report PATH ...`` + render pyperf JSON files, or directories of them, as a Markdown table +``list`` + print the case names + +The timed process imports whichever python-sbom the running interpreter has installed, so comparing two revisions +means running the same harness from two environments. +""" + +from __future__ import annotations + +import argparse +import cProfile +import io +import os +import pstats +import shutil +import subprocess +import sys +import tempfile +import time +from operator import itemgetter +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from ._cases import CASES, TIERS, make_step +from ._fixtures import load, prepare + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from ._cases import Step + +_ROOT: Final[Path] = Path(__file__).parents[1] +_PYTHON: Final[str] = f"{sys.implementation.name}{sys.version_info.major}.{sys.version_info.minor}" +_TOP: Final[int] = 15 +_CLOCKS: Final[tuple[str, ...]] = ("wall", "cpu") + + +def main(arguments: Sequence[str]) -> None: + if arguments[:1] == ["worker"]: + _worker(arguments[1], Path(arguments[2]), arguments[3], arguments[4:]) + return + if arguments[:1] == ["profile-worker"]: + _profile_case(arguments[1], Path(arguments[2]), Path(arguments[3])) + return + passthrough: list[str] = [] + if "--" in arguments: + split = arguments.index("--") + arguments, passthrough = arguments[:split], list(arguments[split + 1 :]) + options = _parser().parse_args(arguments) + match options.command: + case "prepare": + prepare(options.work, options.project, options.virtualenv) + case "run": + out = options.out / (_PYTHON if options.clock == "wall" else f"{_PYTHON}-{options.clock}") + _run(options.work, out, _selected(options.cases), options.clock, passthrough) + case "profile": + _profile(options.work, options.out / _PYTHON / "profile", _selected(options.cases), py_spy=options.py_spy) + case "once": + _once(options.work, options.case) + case "report": + sys.stdout.write(_report(options.paths)) + case "list": + sys.stdout.write("".join(f"{name}\n" for name in CASES)) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m bench") + commands = parser.add_subparsers(dest="command", required=True) + work = argparse.ArgumentParser(add_help=False) + work.add_argument("--work", type=Path, default=_ROOT / ".tox" / "bench-work", help="fixture directory") + output = argparse.ArgumentParser(add_help=False) + output.add_argument("--out", type=Path, default=_ROOT / ".tox" / "bench-results", help="result directory") + setup = commands.add_parser("prepare", parents=[work], help="build the fixtures") + setup.add_argument("--project", type=Path, default=_ROOT, help="python-sbom checkout whose own wheel to build") + setup.add_argument("--virtualenv", type=Path, help="virtualenv checkout carrying [tool.python-sbom]") + for name, text in (("run", "time cases with pyperf"), ("profile", "profile cases once")): + command = commands.add_parser(name, parents=[work, output], help=text) + command.add_argument("cases", nargs="*", metavar="CASE", help="cases to run (default: all)") + commands.choices["run"].add_argument( + "--clock", + choices=_CLOCKS, + default="wall", + help="wall time, or the CPU time of the process and its children, steadier on a loaded machine", + ) + commands.choices["profile"].add_argument("--py-spy", action="store_true", help="also record with py-spy") + commands.add_parser("once", parents=[work], help="run one case once, for an outside profiler").add_argument("case") + commands.add_parser("report", help="render results as Markdown").add_argument("paths", nargs="+", type=Path) + commands.add_parser("list", help="print the case names") + return parser + + +def _selected(names: list[str]) -> list[str]: + if unknown := sorted(set(names) - CASES.keys()): + msg = f"unknown case(s) {', '.join(unknown)}; known: {', '.join(CASES)}" + raise SystemExit(msg) + return names or list(CASES) + + +def _run(work: Path, out: Path, names: list[str], clock: str, passthrough: list[str]) -> None: + out.mkdir(parents=True, exist_ok=True) + fixtures: Final = load(work) + for name in names: + if missing := [key for key in CASES[name].needs if key not in fixtures]: + sys.stdout.write(f"skip {name}: no fixture {', '.join(missing)}\n") + continue + (result := out / f"{name}.json").unlink(missing_ok=True) + subprocess.run( + [ + sys.executable, + "-m", + "bench", + "worker", + name, + str(work), + clock, + "--output", + str(result), + "--inherit-environ", + "PYTHONPATH", + *TIERS[CASES[name].tier], + *passthrough, + ], + check=True, + ) + + +def _worker(name: str, work: Path, clock: str, arguments: Sequence[str]) -> None: + import pyperf # ruff: ignore[import-outside-top-level] # only the timed processes need it + + runner = pyperf.Runner(program_args=("-m", "bench", "worker", name, str(work), clock)) + runner.parse_args(list(arguments)) + with tempfile.TemporaryDirectory(dir=work, prefix="scratch-") as scratch: + step = make_step(CASES[name], load(work), Path(scratch)) + runner.metadata["python_sbom_version"] = _python_sbom_version() + runner.metadata["bench_clock"] = clock + runner.bench_time_func(name, _timed, step, time.perf_counter if clock == "wall" else _cpu_time) + + +def _timed(loops: int, step: Step, clock: Callable[[], float]) -> float: + total = 0.0 + for _ in range(loops): + if step.reset is not None: + step.reset() + start = clock() + step.run() + total += clock() - start + return total + + +def _cpu_time() -> float: + # children count once waited for, which covers git and the cold cases' interpreter + import resource # ruff: ignore[import-outside-top-level] # Unix only, and only the cpu clock needs it + + own, children = resource.getrusage(resource.RUSAGE_SELF), resource.getrusage(resource.RUSAGE_CHILDREN) + return own.ru_utime + own.ru_stime + children.ru_utime + children.ru_stime + + +def _once(work: Path, name: str) -> None: + with tempfile.TemporaryDirectory(dir=work, prefix="scratch-") as scratch: + step = make_step(CASES[name], load(work), Path(scratch)) + if step.reset is not None: + step.reset() + step.run() + + +def _profile(work: Path, out: Path, names: list[str], *, py_spy: bool) -> None: + out.mkdir(parents=True, exist_ok=True) + fixtures: Final = load(work) + summary: Final[list[str]] = [] + for name in names: + if any(key not in fixtures for key in CASES[name].needs): + continue + stats_file = out / f"{name}.pstats" + # each case in a fresh process, so one case's warm caches never shorten another's profile + subprocess.run([sys.executable, "-m", "bench", "profile-worker", name, str(work), str(stats_file)], check=True) + summary.append(_hot_spots(name, stats_file)) + (out / f"{name}.txt").write_text(_stats_text(stats_file), encoding="utf-8") + # next to the interpreter too, as a venv's scripts are when its python runs without activating it + if py_spy and ( + spy := shutil.which("py-spy", path=f"{Path(sys.executable).parent}{os.pathsep}{os.environ.get('PATH', '')}") + ): + subprocess.run( + [ + spy, + "record", + "--format", + "speedscope", + "--rate", + "500", + "--output", + str(out / f"{name}.speedscope.json"), + "--", + sys.executable, + "-m", + "bench", + "once", + name, + "--work", + str(work), + ], + check=True, + ) + (target := out / "hot-spots.md").write_text("\n".join(summary), encoding="utf-8") + sys.stdout.write(f"wrote {target}\n") + + +def _profile_case(name: str, work: Path, stats_file: Path) -> None: + with tempfile.TemporaryDirectory(dir=work, prefix="scratch-") as scratch: + step = make_step(CASES[name], load(work), Path(scratch)) + if step.command is not None: + if step.reset is not None: + step.reset() + command = [step.command[0], "-m", "cProfile", "-o", str(stats_file), *step.command[1:]] + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + return + if CASES[name].tier != "huge": # warm, as the timed values are; the huge case loads its validator on import + if step.reset is not None: + step.reset() + step.run() + if step.reset is not None: + step.reset() + profiler = cProfile.Profile() + profiler.runcall(step.run) + profiler.dump_stats(stats_file) + + +def _stats_text(stats_file: Path) -> str: + buffer = io.StringIO() + stats = pstats.Stats(str(stats_file), stream=buffer) + stats.sort_stats("cumulative").print_stats(40) + stats.sort_stats("tottime").print_stats(40) + return buffer.getvalue() + + +def _hot_spots(name: str, stats_file: Path) -> str: + entries = pstats.Stats(str(stats_file)).stats # ty: ignore[unresolved-attribute] # the table pstats keeps is not in its stubs + total = sum(entry[2] for entry in entries.values()) or 1.0 + rows = [ + (_label(function), entry[1], entry[0], entry[2], entry[3]) + for function, entry in entries.items() + if not _harness(function) + ] + lines = [f"### {name}", "", f"profiled total {total:.3f}s", ""] + for title, key in (("cumulative", 4), ("own time", 3)): + lines += [ + f"by {title}:", + "", + "| function | calls | own s | cumulative s | cumulative % |", + "| --- | ---: | ---: | ---: | ---: |", + ] + lines += [ + f"| `{label}` | {calls if calls == primitive else f'{calls}/{primitive}'} | {own:.3f} | {cumulative:.3f} |" + f" {100 * cumulative / total:.1f} |" + for label, calls, primitive, own, cumulative in sorted(rows, key=itemgetter(key), reverse=True)[:_TOP] + ] + lines.append("") + return "\n".join(lines) + + +def _harness(function: tuple[str, int, str]) -> bool: + file, _, func = function + return Path(file).parent == Path(__file__).parent or func in { + "", + "", + } + + +def _label(function: tuple[str, int, str]) -> str: + file, line, func = function + if file == "~": + return func + parts = Path(file).parts + for anchor in ("site-packages", "lib"): + if anchor in parts: + parts = parts[len(parts) - parts[::-1].index(anchor) :] + break + return f"{'/'.join(parts[-3:])}:{line}({func})" + + +def _report(paths: list[Path]) -> str: + import pyperf # ruff: ignore[import-outside-top-level] # see _worker + + files = sorted(file for path in paths for file in (path.glob("*.json") if path.is_dir() else [path])) + lines = [ + "| path | python | median | IQR (p25 - p75) | min - max | values |", + "| --- | --- | ---: | ---: | ---: | ---: |", + ] + for file in files: + benchmark = pyperf.Benchmark.load(str(file)) + values = benchmark.get_values() + python = benchmark.get_metadata().get("python_version", "?") + lines.append( + f"| {benchmark.get_name()} | {python} | {_seconds(benchmark.median())} |" + f" {_seconds(benchmark.percentile(25))} - {_seconds(benchmark.percentile(75))} |" + f" {_seconds(min(values))} - {_seconds(max(values))} | {len(values)} |" + ) + return "\n".join(lines) + "\n" + + +def _seconds(value: float) -> str: + return f"{value:.2f} s" if value >= 1 else f"{value * 1000:.1f} ms" + + +def _python_sbom_version() -> str: + from python_sbom import __version__ # ruff: ignore[import-outside-top-level] # see _worker + + return __version__ + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/bench/_cases.py b/bench/_cases.py new file mode 100644 index 0000000..cf0acf6 --- /dev/null +++ b/bench/_cases.py @@ -0,0 +1,215 @@ +""" +The timed paths: each case turns the fixtures into an untimed reset and a timed step. + +A case runs in its own pyperf process tree, so module caches one case fills never speed up another. Steady-state cases +time the step in a warm process, as a build backend or a long-lived tool sees it; the ``cold-`` cases time a fresh +``python -m python_sbom`` process, imports and schema loading included, as a shell user sees it. +""" + +from __future__ import annotations + +import contextlib +import inspect +import io +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from ._fixtures import SOURCE_DATE_EPOCH + +if TYPE_CHECKING: + from collections.abc import Callable + +#: pyperf options per cost: many calibrated loops for cheap steps, fewer single-loop values for slow ones +TIERS: Final[dict[str, tuple[str, ...]]] = { + "fast": ("--processes", "10", "--values", "5", "--warmups", "1"), + "slow": ("--processes", "6", "--values", "3", "--warmups", "1", "--loops", "1"), + "huge": ("--processes", "3", "--values", "1", "--warmups", "0", "--loops", "1"), +} + + +@dataclass(frozen=True, slots=True) +class Step: + run: Callable[[], object] + reset: Callable[[], object] | None = None + #: the command a cold case runs, which the profiler runs under cProfile instead of profiling the subprocess call + command: tuple[str, ...] | None = None + + +@dataclass(frozen=True, slots=True) +class Case: + name: str + tier: str + needs: tuple[str, ...] + make: Callable[[dict[str, str], Path], Step] + + +def _hatch(root_key: str, *, hooks: bool) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: + from hatchling.builders.wheel import WheelBuilder # ruff: ignore[import-outside-top-level] # only hatch cases + + if not hooks: + os.environ["HATCH_BUILD_NO_HOOKS"] = "true" + root = fixtures[root_key] + return Step( + run=lambda: list(WheelBuilder(root).build(directory=str(scratch), versions=["standard"])), + reset=lambda: _empty(scratch), + ) + + return make + + +def _embed(*, cold: bool) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: + wheel = scratch / Path(fixtures["meson-wheel"]).name + arguments = ("embed", str(wheel), "--root", fixtures["meson"], "--build-env", fixtures["fat-env"]) + return Step( + run=_cold(arguments) if cold else _cli(arguments), + reset=lambda: shutil.copyfile(fixtures["meson-wheel"], wheel), + command=_command(arguments) if cold else None, + ) + + return make + + +def _archive(fixtures: dict[str, str], scratch: Path) -> Step: + return Step( + run=_cli(( + "archive", + fixtures["zipapp"], + "--root", + fixtures["virtualenv"], + "--build-script", + "tasks/make_zipapp.py", + "--output", + str(scratch / "virtualenv.pyz.cdx.json"), + )) + ) + + +def _validate( + key: str, *, expect_valid: bool = True, max_components: int | None = None +) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + from python_sbom._validate._artifact import validate # ruff: ignore[import-outside-top-level] # loads the schema + + path = Path(fixtures[key]) + # revisions before the component cap take no limit and check any count, which is what a raised limit times + limit = ( + () + if max_components is None or "max_components" not in inspect.signature(validate).parameters + else (max_components,) + ) + + def run() -> None: + if (problems := validate(path, None, *limit)) and expect_valid: + msg = f"{path.name} is invalid: {problems}" + raise RuntimeError(msg) + + return Step(run=run) + + return make + + +def _cold_validate(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + arguments = ("validate", fixtures["own-wheel"]) + return Step(run=_cold(arguments), command=_command(arguments)) + + +def _spdx(fixtures: dict[str, str], scratch: Path) -> Step: + return Step(run=_cli(("spdx", fixtures["zipapp-sbom"], str(scratch / "virtualenv.spdx.json")))) + + +def _collect_facts(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + from python_sbom._collect._facts import collect_facts # ruff: ignore[import-outside-top-level] # see _validate + + root, path = Path(fixtures["meson"]), [fixtures["fat-env"]] + # no revision, so only reading the environment's distributions is timed + return Step(run=lambda: collect_facts(root, lambda _root: None, path=path)) + + +def _bundled(key: str) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + from python_sbom._collect._source import read_bundled_wheel # ruff: ignore[import-outside-top-level] # see _validate + + wheel = Path(fixtures[key]) + data = wheel.read_bytes() + return Step(run=lambda: read_bundled_wheel(data, wheel.name)) + + return make + + +CASES: Final[dict[str, Case]] = { + case.name: case + for case in ( + Case("hatch-small", "slow", ("small",), _hatch("small", hooks=True)), + Case("hatch-small-nohook", "slow", ("small",), _hatch("small", hooks=False)), + Case("hatch-virtualenv", "slow", ("virtualenv",), _hatch("virtualenv", hooks=True)), + Case("hatch-virtualenv-nohook", "slow", ("virtualenv",), _hatch("virtualenv", hooks=False)), + Case("embed-meson-fat-env", "slow", ("meson", "meson-wheel", "fat-env"), _embed(cold=False)), + Case("cold-embed-meson-fat-env", "slow", ("meson", "meson-wheel", "fat-env"), _embed(cold=True)), + Case("archive-zipapp", "slow", ("virtualenv", "zipapp"), _archive), + Case("validate-own-wheel", "slow", ("own-wheel",), _validate("own-wheel")), + Case("cold-validate-own-wheel", "slow", ("own-wheel",), _cold_validate), + Case("validate-virtualenv-wheel", "slow", ("virtualenv-wheel",), _validate("virtualenv-wheel")), + Case("validate-zipapp", "slow", ("zipapp-embedded",), _validate("zipapp-embedded")), + # the default limit: a revision with the component cap times the rejection, one without the full schema check + Case("validate-hostile-10k", "huge", ("hostile",), _validate("hostile", expect_valid=False)), + # a limit above the count, so every revision times the full schema check + Case( + "validate-hostile-10k-uncapped", + "huge", + ("hostile",), + _validate("hostile", expect_valid=False, max_components=20_000), + ), + Case("spdx-zipapp", "fast", ("zipapp-sbom",), _spdx), + Case("collect-facts-fat-env", "slow", ("meson", "fat-env"), _collect_facts), + Case("bundled-wheel-pip", "fast", ("pip-wheel",), _bundled("pip-wheel")), + Case("bundled-wheel-setuptools", "fast", ("setuptools-wheel",), _bundled("setuptools-wheel")), + ) +} + + +def make_step(case: Case, fixtures: dict[str, str], scratch: Path) -> Step: + os.environ["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH + if missing := [key for key in case.needs if key not in fixtures]: + msg = f"{case.name} needs the fixture(s) {', '.join(missing)}; prepare with --virtualenv to build them" + raise SystemExit(msg) + return case.make(fixtures, scratch) + + +def _cli(arguments: tuple[str, ...]) -> Callable[[], None]: + from python_sbom._cli._main import main # ruff: ignore[import-outside-top-level] # see _validate + + def run() -> None: + with contextlib.redirect_stdout(io.StringIO()): + main(list(arguments)) + + return run + + +def _command(arguments: tuple[str, ...]) -> tuple[str, ...]: + return (sys.executable, "-m", "python_sbom", *arguments) + + +def _cold(arguments: tuple[str, ...]) -> Callable[[], object]: + command = _command(arguments) + return lambda: subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + + +def _empty(directory: Path) -> None: + shutil.rmtree(directory, ignore_errors=True) + directory.mkdir(parents=True) + + +__all__ = [ + "CASES", + "TIERS", + "Case", + "Step", + "make_step", +] diff --git a/bench/_fixtures.py b/bench/_fixtures.py new file mode 100644 index 0000000..fe0f65e --- /dev/null +++ b/bench/_fixtures.py @@ -0,0 +1,316 @@ +""" +Build every input the benchmarks read, once, into a work directory. + +This is the only step that needs the network: the fat build environment and virtualenv's zipapp download wheels. The +timed runs then read only what this wrote, listed in ``fixtures.json``. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import os +import random +import shutil +import subprocess +import sys +import sysconfig +import zipfile +from pathlib import Path +from typing import Final + +#: a fixed build time, so every SBOM the fixtures and the timed runs write is the same +SOURCE_DATE_EPOCH: Final[str] = "1767225600" +#: resolve the fat environment as of this day, so every machine installs the same distributions +_EXCLUDE_NEWER: Final[str] = "2026-10-01T00:00:00Z" +# packages whose dependency closure gives a build environment of about seventy distributions, pure and platform ones, +# among them botocore, whose RECORD lists thousands of files +_FAT_PACKAGES: Final[tuple[str, ...]] = ( + "black", + "boto3", + "click", + "cryptography", + "httpx", + "hypothesis", + "jinja2", + "pydantic", + "pytest", + "pyyaml", + "requests", + "rich", + "setuptools", + "sphinx", + "tox", + "wheel", +) +_HOSTILE_COMPONENTS: Final[int] = 10_000 +_LICENSE: Final[str] = ( + "MIT License\n\nCopyright (c) 2026 Bench Authors\n\nPermission is hereby granted, free of charge.\n" +) +_GIT_IDENTITY: Final[tuple[str, ...]] = ("-c", "user.name=bench", "-c", "user.email=bench@example.org") + + +def prepare(work: Path, project: Path, virtualenv: Path | None) -> None: + work.mkdir(parents=True, exist_ok=True) + os.environ["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH + fixtures: Final[dict[str, str]] = {} + fixtures["small"] = str(_small_project(work / "small")) + fixtures["small-wheel"] = str(_hatchling_wheel(work / "small", work / "small-dist")) + fixtures["hostile"] = str(_hostile_document(Path(fixtures["small-wheel"]), work / "hostile-10k.cdx.json")) + fixtures["meson"] = str(_meson_project(work / "meson")) + fixtures["meson-wheel"] = str(_meson_wheel(work / "meson-dist")) + fixtures["fat-env"] = _fat_environment(work / "fat-env") + _snapshot(project, work / "python-sbom") + fixtures["own-wheel"] = str(_hatchling_wheel(work / "python-sbom", work / "own-dist")) + if virtualenv is not None: + fixtures.update(_virtualenv(virtualenv, work)) + (work / "fixtures.json").write_text(json.dumps(fixtures, indent=2) + "\n", encoding="utf-8") + sys.stdout.write(f"wrote {work / 'fixtures.json'}\n") + + +def _small_project(root: Path) -> Path: + # a pure-Python project of forty modules that turns the hook on, as a typical hatchling user does + shutil.rmtree(root, ignore_errors=True) + (package := root / "src" / "proj").mkdir(parents=True) + (root / "LICENSE").write_text(_LICENSE, encoding="utf-8") + (root / "pyproject.toml").write_text( + """\ +[build-system] +build-backend = "hatchling.build" +requires = [ "hatchling", "python-sbom[hatch]" ] + +[project] +name = "proj" +version = "1.0" +license = "MIT" +license-files = [ "LICENSE" ] +requires-python = ">=3.11" +dependencies = [ "packaging>=24" ] + +[tool.hatch.build.targets.wheel] +packages = [ "src/proj" ] + +[tool.hatch.build.targets.wheel.hooks.python-sbom] + +[tool.python-sbom] +supplier = { name = "Bench Authors", url = [ "https://example.org" ] } +repository = "https://github.com/example/proj" +""", + encoding="utf-8", + ) + (package / "__init__.py").write_text('"""A small project."""\n', encoding="utf-8") + for index in range(40): + (package / f"module_{index:02d}.py").write_text( + "".join(f"def function_{name}() -> int:\n return {name}\n\n\n" for name in range(50)), encoding="utf-8" + ) + _commit(root) + return root + + +def _commit(root: Path) -> None: + # a checkout, as projects build from one, so the timed runs include reading the source revision with git + _run(["git", "init", "--quiet", str(root)]) + _run(["git", "-C", str(root), "add", "."]) + _run([ + "git", + "-C", + str(root), + *_GIT_IDENTITY, + "commit", + "--quiet", + "--no-verify", + "--no-gpg-sign", + "-m", + "bench fixture", + ]) + + +def _run(command: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None) -> None: + subprocess.run(command, check=True, cwd=cwd, env=env) + + +def _hatchling_wheel(root: Path, directory: Path) -> Path: + # without isolation, in this environment, so the hook under test is the one installed here + shutil.rmtree(directory, ignore_errors=True) + _run([sys.executable, "-m", "hatchling", "build", "--target", "wheel", "--directory", str(directory)], cwd=root) + (wheel,) = directory.glob("*.whl") + return wheel + + +def _hostile_document(wheel: Path, target: Path) -> Path: + # ten thousand unique components: over the default component cap, which rejects the document before the schema + # check, and with a raised limit a uniqueItems check that compares every pair of them + with zipfile.ZipFile(wheel) as archive: + (name,) = (member for member in archive.namelist() if "/sboms/" in member) + document = json.loads(archive.read(name)) + hostile = copy.deepcopy(document) + hostile["components"] = [ + {"type": "library", "bom-ref": f"r{index}", "name": f"n{index}"} for index in range(_HOSTILE_COMPONENTS) + ] + target.write_text(json.dumps(hostile), encoding="utf-8") + return target + + +def _meson_project(root: Path) -> Path: + # the project directory `python-sbom embed` reads [tool.python-sbom] and the source revision from + shutil.rmtree(root, ignore_errors=True) + root.mkdir(parents=True) + (root / "LICENSE").write_text(_LICENSE, encoding="utf-8") + (root / "pyproject.toml").write_text( + """\ +[build-system] +build-backend = "mesonpy" +requires = [ "meson-python" ] + +[project] +name = "meson-proj" +version = "1.0" +license = "MIT" +license-files = [ "LICENSE" ] + +[tool.python-sbom] +supplier = { name = "Bench Authors", url = [ "https://example.org" ] } +""", + encoding="utf-8", + ) + _commit(root) + return root + + +def _meson_wheel(directory: Path) -> Path: + # what meson-python writes: a platform wheel holding an extension module next to its Python sources + shutil.rmtree(directory, ignore_errors=True) + directory.mkdir(parents=True) + abi = f"cp{sys.version_info.major}{sys.version_info.minor}" + platform = sysconfig.get_platform().replace("-", "_").replace(".", "_") + dist_info = "meson_proj-1.0.dist-info" + generator = random.Random(0) # ruff: ignore[suspicious-non-cryptographic-random-usage] # only needs the same bytes on every run + members: Final[dict[str, bytes]] = { + "meson_proj/__init__.py": b'"""A meson-python project."""\n', + f"meson_proj/_core{sysconfig.get_config_var('EXT_SUFFIX')}": generator.randbytes(4 * 1024 * 1024), + **{ + f"meson_proj/module_{index:02d}.py": "".join( + f"def function_{name}() -> int:\n return {name}\n\n\n" for name in range(50) + ).encode("utf-8") + for index in range(30) + }, + f"{dist_info}/METADATA": ( + b"Metadata-Version: 2.4\nName: meson-proj\nVersion: 1.0\nLicense-Expression: MIT\nLicense-File: LICENSE\n" + b"Requires-Python: >=3.11\nRequires-Dist: requests>=2\n" + ), + f"{dist_info}/WHEEL": ( + f"Wheel-Version: 1.0\nGenerator: meson\nRoot-Is-Purelib: false\nTag: {abi}-{abi}-{platform}\n" + ).encode(), + f"{dist_info}/licenses/LICENSE": _LICENSE.encode(), + } + record = "".join( + f"{name},sha256={base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b'=').decode()},{len(data)}\n" + for name, data in members.items() + ) + wheel = directory / f"meson_proj-1.0-{abi}-{abi}-{platform}.whl" + with zipfile.ZipFile(wheel, "w", zipfile.ZIP_DEFLATED) as handle: + for name, data in {**members, f"{dist_info}/RECORD": f"{record}{dist_info}/RECORD,,\n".encode()}.items(): + handle.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data, zipfile.ZIP_DEFLATED) + return wheel + + +def _fat_environment(target: Path) -> str: + if (uv := shutil.which("uv")) is None: + msg = "the fat build environment needs uv on PATH" + raise SystemExit(msg) + shutil.rmtree(target, ignore_errors=True) + _run([uv, "venv", "--quiet", "--python", sys.executable, str(target)]) + python = target / ("Scripts" if sys.platform == "win32" else "bin") / "python" + _run([ + uv, + "pip", + "install", + "--quiet", + "--python", + str(python), + "--exclude-newer", + _EXCLUDE_NEWER, + *_FAT_PACKAGES, + ]) + return _output([str(python), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"]).strip() + + +def _output(command: list[str]) -> str: + return subprocess.run(command, check=True, capture_output=True, text=True).stdout + + +def _snapshot(source: Path, target: Path) -> Path: + # a clone of the checkout plus its uncommitted changes, so builds write their version files there and never into + # the checkout itself; --shared borrows its objects, so even a detached HEAD resolves + shutil.rmtree(target, ignore_errors=True) + _run(["git", "clone", "--quiet", "--shared", "--no-checkout", str(source), str(target)]) + head = _output(["git", "-C", str(source), "rev-parse", "HEAD"]).strip() + _run(["git", "-C", str(target), "checkout", "--quiet", "--detach", head]) + if diff := subprocess.run( + ["git", "-C", str(source), "diff", "--binary", "HEAD"], check=True, capture_output=True + ).stdout: + subprocess.run(["git", "-C", str(target), "apply", "--index"], check=True, input=diff) + for name in _output(["git", "-C", str(source), "ls-files", "--others", "--exclude-standard", "-z"]).split("\0"): + if name: + (target / name).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / name, target / name) + return target + + +def _virtualenv(source: Path, work: Path) -> dict[str, str]: + root = _snapshot(source, work / "virtualenv") + wheel = _hatchling_wheel(root, work / "virtualenv-dist") + zipapp = work / "virtualenv.pyz" + _run( + [sys.executable, "-m", "tasks.make_zipapp", "--wheel-dir", str(wheel.parent), "--dest", str(zipapp)], + cwd=root, + env={**os.environ, "PYTHONPATH": str(root)}, + ) + embedded = work / "virtualenv-sbom.pyz" + shutil.copyfile(zipapp, embedded) + sbom = work / "virtualenv.pyz.cdx.json" + _run([ + sys.executable, + "-m", + "python_sbom", + "archive", + str(embedded), + "--root", + str(root), + "--build-script", + "tasks/make_zipapp.py", + "--output", + str(sbom), + "--embed", + ]) + embed = root / "src" / "virtualenv" / "seed" / "wheels" / "embed" + return { + "virtualenv": str(root), + "virtualenv-wheel": str(wheel), + "zipapp": str(zipapp), + "zipapp-embedded": str(embedded), + "zipapp-sbom": str(sbom), + # the newest of each, the wheels virtualenv seeds on current Pythons + "pip-wheel": str(max(embed.glob("pip-*.whl"), key=_wheel_version)), + "setuptools-wheel": str(max(embed.glob("setuptools-*.whl"), key=_wheel_version)), + } + + +def _wheel_version(wheel: Path) -> tuple[int, ...]: + return tuple(int(part) for part in wheel.name.split("-")[1].split(".") if part.isdigit()) + + +def load(work: Path) -> dict[str, str]: + if not (manifest := work / "fixtures.json").is_file(): + msg = f"{manifest} does not exist; run `python -m bench prepare` first" + raise SystemExit(msg) + return json.loads(manifest.read_text(encoding="utf-8")) + + +__all__ = [ + "SOURCE_DATE_EPOCH", + "load", + "prepare", +] diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..8e33bc6 --- /dev/null +++ b/docs/__init__.py @@ -0,0 +1 @@ +"""Sphinx configuration, importable for static analysis.""" diff --git a/docs/_static/changelog-toc.css b/docs/_static/changelog-toc.css new file mode 100644 index 0000000..add3823 --- /dev/null +++ b/docs/_static/changelog-toc.css @@ -0,0 +1,12 @@ +@media (min-width: 64em) and (max-width: 82em) { + .toc-content-icon { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 10; + padding: 0.5rem; + background: var(--color-background-primary); + border: 1px solid var(--color-foreground-border); + border-radius: 0.25rem; + } +} diff --git a/docs/changelog.d/+python-sbom.feature.rst b/docs/changelog.d/+python-sbom.feature.rst new file mode 100644 index 0000000..d356c8f --- /dev/null +++ b/docs/changelog.d/+python-sbom.feature.rst @@ -0,0 +1,3 @@ +Add the ``python-sbom`` hatchling build hook, which writes a CycloneDX 1.6 SBOM into each wheel, the ``python-sbom`` +command that embeds the same SBOM into wheels other build backends build and renders or validates it, and the Python API +behind both. diff --git a/docs/changelog.d/.gitkeep b/docs/changelog.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..9e3a308 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,4 @@ +Changelog +========= + +.. towncrier release notes start diff --git a/docs/cli.rst b/docs/cli.rst new file mode 100644 index 0000000..8d3ba9a --- /dev/null +++ b/docs/cli.rst @@ -0,0 +1,5 @@ +.. sphinx_argparse_cli:: + :module: python_sbom._cli._parser + :func: build_parser + :title: Command line interface + :group_title_prefix: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..f2de70c --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,133 @@ +"""Documentation generation configuration.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from tomllib import loads +from typing import TYPE_CHECKING, Final + +import python_sbom +from python_sbom import __version__ + +if TYPE_CHECKING: + from docutils.nodes import Element, reference + from sphinx.addnodes import pending_xref + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +def _inheritance_aliases() -> dict[str, str]: + aliases: dict[str, str] = {} + for entry in python_sbom.__all__: + to_module = getattr(getattr(python_sbom, entry), "__module__", "") + # first instance wins + aliases.setdefault(f"{to_module}{'.' if to_module else ''}{entry}", f"{python_sbom.__name__}.{entry}") + return aliases + + +project: Final[str] = "python-sbom" +_NOW: Final[datetime] = datetime.now(tz=UTC) +globals()["copyright"] = f"2026-{_NOW.year}" +version: Final[str] = __version__ +release: Final[str] = __version__.split("+")[0] + +extensions: Final[list[str]] = [ + "sphinx.ext.autosectionlabel", + "sphinx.ext.autodoc", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinx.ext.inheritance_diagram", + "sphinx_argparse_cli", + "sphinx_autodoc_typehints", + "sphinxcontrib.mermaid", +] + +master_doc: Final[str] = "index" +source_suffix: Final[str] = ".rst" +exclude_patterns: Final[list[str]] = ["changelog.d/*"] +autosectionlabel_maxdepth: Final[int] = 2 + +html_theme: Final[str] = "furo" +html_title: Final[str] = "python-sbom: SBOMs for Python distributions" +html_last_updated_fmt: Final[str] = _NOW.isoformat() +html_static_path: Final[list[str]] = ["_static"] +html_css_files: Final[list[str]] = ["changelog-toc.css"] +pygments_style: Final[str] = "sphinx" +pygments_dark_style: Final[str] = "monokai" + +autoclass_content: Final[str] = "both" +inheritance_alias: Final[dict[str, str]] = _inheritance_aliases() +inheritance_graph_attrs: Final[dict[str, str]] = {"rankdir": "TB"} +# each directive names the members it documents, so hatchling's lifecycle methods and inherited members stay out +autodoc_default_options: Final[dict[str, bool | str]] = {"member-order": "bysource", "show-inheritance": True} +typehints_fully_qualified: Final[bool] = False +always_document_param_types: Final[bool] = True +typehints_document_rtype: Final[bool] = True +typehints_defaults: Final[str] = "comma" + +intersphinx_mapping: Final[dict[str, tuple[str, None]]] = {"python": ("https://docs.python.org/3.11", None)} +extlinks: Final[dict[str, tuple[str, str]]] = { + "pr": ("https://github.com/tox-dev/python-sbom/pull/%s", "#%s"), + "user": ("https://github.com/%s", "@%s"), +} +nitpicky: Final[bool] = True +nitpick_ignore: Final[list[tuple[str, str]]] = [ + # hatchling publishes no Sphinx inventory + ("py:class", "hatchling.builders.hooks.plugin.interface.BuildHookInterface"), + ("py:class", "hatchling.metadata.core.ProjectMetadata"), + ("py:class", "hatchling.bridge.app.Application"), +] + + +def setup(app: Sphinx) -> None: + # annotations name the private modules that define a type, and pathlib.Path an undocumented private alias + app.connect("missing-reference", _resolve_private_path) + app.connect("source-read", _include_unreleased) + + +def _resolve_private_path( + app: Sphinx, + env: BuildEnvironment, + node: pending_xref, + contnode: Element, +) -> reference | None: + module, _, name = node["reftarget"].rpartition(".") + if node["reftarget"] == "pathlib._local.Path": + node["reftarget"] = "pathlib.Path" + elif module.startswith("python_sbom._") and name in python_sbom.__all__: + node["reftarget"] = f"python_sbom.{name}" + else: + return None + domain = env.get_domain("py") + return domain.resolve_xref(env, node["refdoc"], app.builder, node["reftype"], node["reftarget"], node, contnode) + + +def _include_unreleased(app: Sphinx, docname: str, source: list[str]) -> None: + if docname != "changelog": + return + root = Path(__file__).parent.parent + fragments = root / "docs" / "changelog.d" + app.env.note_dependency(str(fragments)) + sections: list[str] = [] + for category in loads((root / "pyproject.toml").read_text(encoding="utf-8"))["tool"]["towncrier"]["type"]: + entries = [ + f"- {' '.join(fragment.read_text(encoding='utf-8').split())}" + for fragment in sorted(fragments.glob(f"*.{category['directory']}.rst")) + ] + if entries: + sections.extend(( + f".. _release-unreleased-{category['name'].lower().replace(' ', '-')}:", + "", + category["name"], + "~" * len(category["name"]), + "", + *entries, + "", + )) + if sections: + joined = "\n".join(sections) + source[0] = source[0].replace( + ".. towncrier release notes start", + f".. _release-unreleased:\n\nUnreleased\n----------\n\n{joined}\n.. towncrier release notes start", + ) diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..7530dac --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,46 @@ +Contributing +============ + +How the code fits together +-------------------------- + +Every SBOM comes out of one pipeline. An adapter, the hatchling hook or a command of the command line, gathers what the +edge knows: the request (the wheel's core metadata and file name, the project directory, the options and the build +scripts), the source tree (license files and bundled wheels), and the build facts (time, source revision and toolchain). +It hands them, with a content provider, to the pipeline, which builds the document with pure functions in ``_model``. +:doc:`explanation` draws the packages and their imports. + +To record a new field, read it at the edge into ``_model/_inputs.py`` and write it in ``_model``; to add a subcommand, +add a registration function and a handler next to the others in ``_cli/_commands.py`` and list the registration in +``_cli/_parser.py``. Tests mirror the source: ``tests//test_.py``. + +Changelog fragments +------------------- + +Add a changelog fragment under ``docs/changelog.d`` when a pull request changes what package users can use or observe. +Name it ``+short-name..rst``, for example ``+vendored-licenses.feature.rst``. The name describes the change; you +do not need a pull request number before opening the pull request. Write one change per fragment, and use several +fragments when a pull request has several user-visible changes. + +Choose the type by its effect on users: + +- ``feature`` (Added): a new capability, including adding or removing support for a Python version. +- ``change`` (Changes): a change in behavior that remains available. +- ``bugfix`` (Fixed): a correction to existing behavior. +- ``doc`` (Documentation): a documentation change users need to know about. +- ``breaking`` (Breaking changes): a change that requires users to change their usage. +- ``security`` (Security): a security fix users need to act on. +- ``performance`` (Performance): a visible speed or resource improvement. +- ``deprecation`` (Deprecated): a supported behavior scheduled for removal. +- ``removal`` (Removed): removal of a user-facing capability. +- ``packaging`` (Packaging): a change to installation or distributions. + +Apply the ``skip news`` label to a pull request with no user-visible change. You can add the label after opening the +pull request. Supplied fragments are still checked when the label is present. Bot pull requests need no fragment unless +they include one. + +The release workflow links each fragment to its merged pull request and credits its human author. If several people +authored a change, put their `GitHub `_ handles in a ``by`` credit in the fragment; omit bots and +reviewers. The changelog uses linked names, while ``GitHub`` Release notes use ``@handle``. + +Build the documentation with ``tox r -e docs``; ``tox r -e docs -- -b linkcheck`` checks its external links. diff --git a/docs/explanation.rst b/docs/explanation.rst new file mode 100644 index 0000000..f9796c1 --- /dev/null +++ b/docs/explanation.rst @@ -0,0 +1,114 @@ +How it works +============ + +Architecture +------------ + +Each concern has its own subpackage, and only the adapters know a build system or the command line. The hatchling hook +and the ``embed`` and ``archive`` commands gather what the edge knows: the request (core metadata, file name, options, +build scripts), the source tree, and the build facts (time, revision, toolchain). They hand those, with a +:class:`~python_sbom.ContentProvider` and its :class:`~python_sbom.ProviderContext`, to one pipeline, and the model +turns them into the document with pure functions. The diagram follows the imports between the subpackages: an arrow +points at what a package imports, and a dashed arrow at what it imports only for type annotations. + +.. mermaid:: + + flowchart TB + subgraph adapters[Adapters] + hook[hatch
hatchling build hook] + cli[_cli
embed, archive, spdx, validate] + end + api[python_sbom
provider API] + generate[_generate
wheel and archive pipelines] + collect[_collect
settings, facts, source tree, provider loading] + validate[_validate
schema, references, licenses] + formats[_formats
CycloneDX JSON, SPDX 2.3] + wheel[_wheel
RECORD rows, archive reads and rewrite] + model[_model
inputs, types, identity, document building] + hook --> generate + hook --> collect + hook -.-> model + cli --> generate + cli --> collect + cli --> validate + cli --> formats + api --> model + generate --> collect + generate --> formats + generate --> wheel + generate --> model + collect --> wheel + collect --> model + validate --> formats + validate --> wheel + validate --> model + formats --> model + wheel --> model + classDef edge fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; + classDef core fill:#d8f3dc,stroke:#2d6a4f,color:#081c15; + class hook,cli,collect,api edge; + class generate,validate,formats,wheel,model core; + +The model imports nothing else of the package but its errors, and only the hook imports hatchling, so the provider +API and the command line work without it. Every package raises subclasses of :class:`~python_sbom.SbomError`, which the +command line reports without a traceback. + +The one extension point is the :class:`~python_sbom.ContentProvider`, which every entry point loads from the +``content-provider`` option. A hatchling custom hook can override +:meth:`~python_sbom.hatch.SbomBuildHook.content_provider` to build the provider itself. + +What the SBOM records +--------------------- + +The document's root component is the project, described from the wheel's ``METADATA``: name, version, summary, +license expression, maintainers, project URLs, classifiers and keywords. Links to the PyPI release, the wheel's +attestation and, with a repository, the license file follow. A build from a git checkout adds the commit and a link to +that exact revision. + +The components below the root are the wheels the project bundles and the runtime dependencies it declares. A bundled +wheel carries its SHA-256, its license, a component per file from its ``RECORD``, and the packages it vendors. A +declared dependency carries no version, because the installer picks it at install time; the document marks the root's +dependencies incomplete for that reason. An extra's dependencies are left out. + +The formulation records how the wheel was built: the interpreter's version and implementation, every distribution of +the build environment with its dependencies, the files and hashes of each pure-Python one, and ``SOURCE_DATE_EPOCH`` +when it set the build time. + +Property names start with the project's normalized name, such as ``virtualenv:bundled-wheel``, or with ``python:`` for +values copied from core metadata. + +Reproducibility +--------------- + +The document holds nothing about the build machine. Its timestamp comes from ``SOURCE_DATE_EPOCH``. When that is +unset, a reproducible hatchling build uses hatchling's fixed 2020-02-02, the time it stamps on the wheel's entries, and +any other build uses the current time. Its serial number derives from the content, so identical documents share one +and any difference gets a new one. + +The build tools are listed down to their files, so a rebuild can check it installed the same bytes. Only what is the +same on every machine goes in: platform wheels appear without their files, because those differ per operating system +and architecture, and editable installs without theirs, because they point at a checkout. Installer metadata, +console-script launchers and uv's cache files are left out too, since they name the frontend, the environment's path or +a time rather than the distribution. + +Hashing each tool's ``RECORD`` instead would be smaller, but that file lists the installer metadata, so two frontends +installing the same tools would give two hashes. The cost is size: python-sbom's own isolated build lists about 600 +files, half of them from setuptools, which setuptools-scm pulls in, for an SBOM of about 400 KB. + +So a rebuild reproduces the wheel byte for byte on any operating system when these match the original build: the +source tree, as a checkout of the same commit; ``SOURCE_DATE_EPOCH``; the Python version; and the versions of the +build backend and its dependencies. The SBOM records the last three, so it says what to install before rebuilding. + +Limitations +----------- + +- The document describes what the project declares and bundles, not the full tree of runtime dependencies, which + depends on the environment the wheel is installed into. +- The links to the PyPI release and attestation assume the project publishes to PyPI with attestations. +- A wheel built from an sdist of a dirty checkout names no commit, since no commit reproduces it. +- ``python-sbom embed`` and ``archive`` record the packages of the environment ``--build-env`` names, by default the + one they run in, and always the interpreter they run on. Unless that is the environment the artifact was built in, + the build tool record does not match the real build. +- The SPDX rendering leaves out per-file hashes and the build record, which SPDX 2.3 cannot hold. +- A tool that renames the wheel after the build, such as ``auditwheel repair``, leaves the attestation link pointing at + the old file name until ``python-sbom embed`` runs on the renamed wheel. diff --git a/docs/extend.rst b/docs/extend.rst new file mode 100644 index 0000000..92e217d --- /dev/null +++ b/docs/extend.rst @@ -0,0 +1,122 @@ +Extend it +========= + +The options cover what every project shares. A project that knows more than its metadata says adds it through a content +provider, and the same provider serves the wheel's SBOM and the SBOM of any other archive the project publishes. + +Add content with a provider +--------------------------- + +A :class:`~python_sbom.ContentProvider` has two methods. Each receives a :class:`~python_sbom.ProviderContext`: the +project directory, its :class:`~python_sbom.Settings`, its :class:`~python_sbom.ProjectIdentity` with the prefix of its +properties, and :meth:`~python_sbom.ProviderContext.read`, which reads a file of the artifact being described, a member +of the built wheel or of the archive. Each returns a value whose fields default to empty, so a provider names only what +it adds. + +- :meth:`~python_sbom.ContentProvider.bundled_wheel` returns the properties and the supplier of one bundled wheel. The + properties follow ``:bundled-wheel`` and come before those from the wheel's metadata. A wheel's supplier comes + only from here, never from the wheel's own metadata. +- :meth:`~python_sbom.ContentProvider.contributions` returns extra top-level components, the dependencies between + them, root properties and build tools, such as the C libraries a native wheel links. They join the document before + the root's dependencies and the serial number derive from it. The build fails when a ``bom-ref`` repeats, a + dependency names one that no component has, or a root property uses the project's own prefix. + +Name the provider in the ``content-provider`` option, as ``module:attribute`` importable from the project root, and the +hook, ``python-sbom embed`` and ``python-sbom archive`` all load it. When the provider's class lives in the project, the +SBOM lists its module among the build tools, hashed, because it shapes the document a rebuild has to reproduce: + +.. code-block:: python + + from python_sbom import ContentProvider, Contributions + + + class Content(ContentProvider): + def contributions(self, context): + zlib = {'type': 'library', 'bom-ref': 'pkg:generic/zlib@1.3.1', 'name': 'zlib', 'version': '1.3.1'} + return Contributions(components=[zlib]) + +Worked example: virtualenv +-------------------------- + +`virtualenv `_ ships the pip and setuptools wheels it installs into new +environments, under ``src/virtualenv/seed/wheels/embed``. A ``BUNDLE_SUPPORT`` table in that package's ``__init__.py``, +after a ``BUNDLE_FOLDER`` assignment, decides which Python versions receive each wheel. Its options name the supplier +and the wheels: + +.. code-block:: toml + + [build-system] + build-backend = 'hatchling.build' + requires = ['hatch-vcs>=0.5', 'hatchling>=1.28', "python-sbom[hatch]>=0.1; python_version >= '3.11'"] + + [tool.hatch.build.hooks.custom] + path = 'hatch_build.py' + + [tool.python-sbom] + supplier = { name = 'Python Packaging Authority', url = ['https://www.pypa.io'] } + bundled-wheels = ['src/virtualenv/seed/wheels/embed/*.whl'] + content-provider = 'tasks.sbom_content:VirtualenvContent' + +virtualenv still builds on Python versions python-sbom does not support, so its ``hatch_build.py`` loads the hook only +where the requirement's marker installs it, and falls back to hatchling's plain hook elsewhere: + +.. code-block:: python + + import sys + + if sys.version_info >= (3, 11): + from python_sbom.hatch import SbomBuildHook + else: # python-sbom needs Python 3.11, so builds on older interpreters carry no SBOM + from hatchling.builders.hooks.plugin.interface import BuildHookInterface as SbomBuildHook + + + def get_build_hook(): + return SbomBuildHook + +The ``get_build_hook`` function tells hatchling which class to load. The SBOM lists the script among the build tools, +hashed. The provider reads the table next to each wheel through the context, by name, without importing the package, +and names the project's supplier as the wheels' supplier, since virtualenv bundles only PyPA's own projects. The test +suite builds a project with virtualenv's layout with the same provider, handed over from a hook subclass: + +.. literalinclude:: ../tests/_seeder/hatch_build.py + :language: python + :pyobject: BundleSupport + +The pip component then carries these properties, in this order: + +.. list-table:: + :header-rows: 1 + + * - Property + - Value + * - ``virtualenv:bundled-wheel`` + - ``virtualenv/seed/wheels/embed/pip-26.1-py3-none-any.whl`` + * - ``virtualenv:seeded-for-python`` + - ``3.14`` + * - ``virtualenv:seeded-for-python`` + - ``3.15`` + * - ``python:requires-python`` + - ``>=3.10`` + +A bundled wheel is named by its path inside the artifact, so the wheel's SBOM and the zipapp's give it the same +``bom-ref``. + +Describe another archive +------------------------ + +virtualenv also publishes a zipapp, ``virtualenv.pyz``, that holds virtualenv's own ``.dist-info`` at its top level, +the wheels it seeds, and its dependencies unpacked under ``__virtualenv__/``. ``python-sbom archive`` describes such an +archive the way the hook describes a wheel: the archive's own files, the project's distribution, each wheel it +bundles as a ``.whl`` member, and each one unpacked into a directory with a ``.dist-info``. The content provider +hears of every bundled wheel, and its context reads the archive's files: + +.. code-block:: console + + $ python-sbom archive virtualenv.pyz --build-script tasks/make_zipapp.py --embed + wrote virtualenv.pyz.cdx.json + wrote virtualenv.pyz.cdx.json into virtualenv.pyz + +The document takes its options, source revision and toolchain the way the wheel's does, so both name the project +alike. Its build time comes from ``SOURCE_DATE_EPOCH``, or the current time without it; set ``SOURCE_DATE_EPOCH`` for +both builds to give them one time. ``--embed`` adds the document to the archive's top level, where ``python-sbom +validate virtualenv.pyz`` finds it. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..1041067 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,43 @@ +python-sbom +=========== + +SBOMs for Python distributions. python-sbom writes a `CycloneDX `_ 1.6 SBOM into every wheel +you build, at ``-.dist-info/sboms/.cdx.json``. That is where `PEP 770 +`_ tells installers to keep SBOMs, so they land in the environment next to the +installed package, where scanners look for them. + +Why a wheel needs its own SBOM +------------------------------ + +General-purpose scanners read the dependencies a package declares or the packages an environment has installed. They +cannot see a wheel your project ships as a data file, and they cannot say how the wheel was built. The SBOM this package +writes records both: the wheels you bundle, with their hashes, licenses and vendored packages, and the Python version +and packages of the build environment. A third party can use that record to rebuild the wheel and compare the bytes. + +Quick start +----------- + +For a project built with `hatchling `_, add the hook to the build requirements and +enable it in ``pyproject.toml``: + +.. code-block:: toml + + [build-system] + build-backend = 'hatchling.build' + requires = ['hatchling>=1.28', 'python-sbom[hatch]>=0.1'] + + [tool.hatch.build.hooks.python-sbom] + +Every wheel you build now carries the SBOM. For a project built with another backend, such as meson-python, run +``python-sbom embed dist/*.whl`` after the build; :doc:`usage` shows both. + +.. toctree:: + :maxdepth: 2 + + usage + extend + explanation + cli + py_api + changelog + contributing diff --git a/docs/py_api.rst b/docs/py_api.rst new file mode 100644 index 0000000..bfdea4b --- /dev/null +++ b/docs/py_api.rst @@ -0,0 +1,87 @@ +Python API +========== + +The Python API is what a project needs to add its own knowledge to the SBOM: the hatchling build hook and the content +provider it calls, with the types those take and return. Everything else, from embedding to validation, runs through +the :doc:`cli`. The core is importable from ``python_sbom`` without hatchling; the hook lives in ``python_sbom.hatch``. + +.. py:data:: python_sbom.__version__ + :type: str + + The installed version of python-sbom. + +Hatchling build hook +-------------------- + +.. autoclass:: python_sbom.hatch.SbomBuildHook + :members: content_provider + +Content provider +---------------- + +.. autoclass:: python_sbom.ContentProvider + :members: bundled_wheel, contributions + +.. autoclass:: python_sbom.ProviderContext + :members: read + +.. autoclass:: python_sbom.BundledWheelInfo + +.. autoclass:: python_sbom.Contributions + +.. autoclass:: python_sbom.Settings + +.. autoclass:: python_sbom.ProjectIdentity + +Component types +--------------- + +These :class:`~typing.TypedDict` types describe the JSON a provider builds. Each follows the CycloneDX 1.6 object of the +same name; the `CycloneDX 1.6 reference `_ describes every key. + +.. py:class:: python_sbom.Component + + A CycloneDX 1.6 component, a distribution, an application, the interpreter or a file, as a dictionary with the keys + the CycloneDX component object defines. python-sbom uses ``type``, ``bom-ref``, ``name``, ``version``, ``purl``, + ``supplier`` (an :class:`~python_sbom.OrganizationalEntity`), ``authors`` (:class:`~python_sbom.Contact`), + ``description``, ``licenses`` (:class:`~python_sbom.License`), ``copyright``, ``hashes`` + (:class:`~python_sbom.Hash`), ``externalReferences`` (:class:`~python_sbom.ExternalReference`), ``properties`` + (:class:`~python_sbom.Property`), ``evidence`` (:class:`~python_sbom.Evidence`) and the nested ``components``. + +.. autoclass:: python_sbom.Property + :no-members: + +.. autoclass:: python_sbom.Dependency + :no-members: + +.. autoclass:: python_sbom.OrganizationalEntity + :no-members: + +.. autoclass:: python_sbom.Contact + :no-members: + +.. autoclass:: python_sbom.ExternalReference + :no-members: + +.. autoclass:: python_sbom.Hash + :no-members: + +.. autoclass:: python_sbom.License + :no-members: + +.. autoclass:: python_sbom.LicenseInfo + :no-members: + +.. autoclass:: python_sbom.Evidence + :no-members: + +.. autoclass:: python_sbom.Identity + :no-members: + +.. autoclass:: python_sbom.IdentityMethod + :no-members: + +Errors +------ + +.. autoexception:: python_sbom.SbomError diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..8c17a75 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,171 @@ +Use it +====== + +With hatchling +-------------- + +Add the hook to ``build-system.requires`` and enable it with an empty table, as the quick start on the :doc:`index` +shows. The ``hatch`` extra brings hatchling; the command line needs none. The hook writes the SBOM into wheels only: +an editable wheel carries none, and an sdist carries the source revision instead, so a wheel built from it still names +its commit. + +The hook writes the SBOM once hatchling has written the wheel, so the document describes the final file name and +metadata, whatever dependencies or tag other hooks changed. Hatchling runs the hooks in the order of their tables; see +its `build hook documentation `_. The order does not matter +for this hook. + +Options +~~~~~~~ + +The options live in a ``[tool.python-sbom]`` table, the one place the hook, ``python-sbom embed`` and ``python-sbom +archive`` all read, so every SBOM of a project names it alike. Each option is optional; an unknown key fails the +build, so a typo cannot pass silently. The hook's own table holds only hatchling's options, and a python-sbom option +there fails the build with a pointer to ``[tool.python-sbom]``. + +``supplier`` + The organization that supplies the project: a table with a ``name`` string and an optional ``url`` string array. It + becomes the supplier and manufacturer of the document and the supplier of the project. Left out, the document names + no supplier. + + .. code-block:: toml + + [tool.python-sbom] + supplier = { name = 'Python Packaging Authority', url = ['https://www.pypa.io'] } + +``repository`` + The repository URL, used for the link to the source revision, the license file and, on GitHub, the security policy + and advisories. It defaults to the first ``Source``, ``Repository`` or ``GitHub`` URL in ``[project.urls]``. + + .. code-block:: toml + + [tool.python-sbom] + repository = 'https://gitlab.com/example/project' + +``default-branch`` + The branch in links to files of the repository, ``main`` by default. + + .. code-block:: toml + + [tool.python-sbom] + default-branch = 'trunk' + +``bundled-wheels`` + Glob patterns, relative to the project root, of wheels the project ships as data files. Each matching wheel becomes + a component with its SHA-256, license, files and the packages it vendors. + + .. code-block:: toml + + [tool.python-sbom] + bundled-wheels = ['src/project/wheels/*.whl'] + +``content-provider`` + A :class:`~python_sbom.ContentProvider`, as ``module:attribute`` importable from the project root, that adds what + the project knows beyond its metadata; :doc:`extend` shows one. Loading it imports, and so runs, that module. + + .. code-block:: toml + + [tool.python-sbom] + content-provider = 'sbom_content:Content' + +A bundled wheel's files come from its ``RECORD``, and the build fails when a member is missing from it, a row names a +member the wheel lacks, or a digest differs from the member's content. Its vendored packages come from +``_vendor/vendor.txt``, pip's manifest, whose lines must each pin one exact version, and from any +``.dist-info/METADATA`` under ``_vendor/``. A bundled wheel gets a supplier only from the project's content provider, +never from its own metadata. + +The source revision +~~~~~~~~~~~~~~~~~~~ + +The SBOM names the commit the wheel was built from. It takes the first source that knows it: + +#. the ``PYTHON_SBOM_COMMIT`` environment variable, or ``--commit`` on the command line; +#. git, when the project's ``pyproject.toml`` is tracked by the checkout that holds it, so a project unpacked inside an + unrelated repository never borrows that repository's commit; +#. ``.python-sbom-revision``, which the hook writes into every sdist built from a clean checkout, so a wheel built from + the sdist, as ``python -m build`` and ``uv build`` do by default, keeps the commit; +#. the ``node`` line of an expanded ``.git_archival.txt``, for a ``git archive``. + +A commit must be a full hash of 40 or 64 hex characters. A checkout with changes, including untracked files that are +not ignored, gets a ``:vcs-dirty`` property, and the link to the revision says so. When git cannot read the +checkout, the build warns and moves on to the next source. git runs with ``core.fsmonitor`` off, so a repository's +configuration runs no command. + +With another build backend +-------------------------- + +A backend other than hatchling, such as `meson-python `_, cannot run the hook. +``python-sbom embed`` writes the same document into a wheel that backend built, adds the ``RECORD`` row, and keeps +every other entry of the archive as it was. It reads the options from ``[tool.python-sbom]``: + +.. code-block:: toml + + [tool.python-sbom] + supplier = { name = 'tox-dev', url = ['https://github.com/tox-dev'] } + +Build, then embed, from the project root: + +.. code-block:: console + + $ python -m build --wheel + $ python-sbom embed dist/*.whl + wrote turbohtml-1.0.dist-info/sboms/turbohtml.cdx.json into turbohtml-1.0-cp314-cp314-linux_x86_64.whl + +``--commit`` names the source revision when the wheel came from an sdist, and ``--content-provider`` overrides the +``content-provider`` option. + +The build tool record lists the packages of the environment named by ``--build-env``, by default the one ``embed`` runs +in; the interpreter it records is always the one running ``embed``. To record the real toolchain, build without +isolation in the environment ``embed`` runs in, from the same lock. On Linux, meson-python also needs ``patchelf`` +there: + +.. code-block:: console + + $ python -m pip install build python-sbom meson-python meson ninja patchelf + $ python -m build --wheel --no-isolation + $ python-sbom embed dist/*.whl + +``auditwheel repair`` renames a Linux wheel to its ``manylinux`` tag, so the attestation link in an SBOM written +before the repair names the old file. Run ``embed`` on the repaired wheel, after ``auditwheel``, to describe the file +you publish; auditwheel's own SBOM in the same directory stays untouched. + +Render it as SPDX +----------------- + +Some tools read only `SPDX `_. ``python-sbom spdx`` renders the CycloneDX document as SPDX 2.3 JSON: + +.. code-block:: console + + $ unzip -p dist/project-1.0-py3-none-any.whl '*/sboms/project.cdx.json' > project.cdx.json + $ python-sbom spdx project.cdx.json project.spdx.json + +The SPDX document keeps the packages, their hashes, licenses and purls, and the containment, dependency and build tool +relationships. It leaves out the per-file hashes, because SPDX 2.3 wants a SHA-1 for every file and wheel ``RECORD`` +files hold SHA-256, and the build record, which SPDX 2.3 has no field for. Its namespace ends in the CycloneDX serial +number, so both documents name the same build. + +Check it in CI +-------------- + +``python-sbom validate`` needs the ``validate`` extra, and says so when it is missing. It checks every CycloneDX SBOM +in each wheel's ``.dist-info/sboms`` against the CycloneDX 1.6 schema. For the SBOM python-sbom wrote, it also requires +the ``urn:uuid`` serial number ``actions/attest`` needs to recognize the format, checks that every reference resolves, +and validates the SPDX rendering; an SBOM another tool wrote, such as auditwheel's, gets the schema check alone: + +.. code-block:: console + + $ python -m pip install 'python-sbom[validate]' + $ python-sbom validate dist/ --allow-license MIT --allow-license Apache-2.0 + +Each ``--allow-license`` adds an SPDX id that bundled wheels may use. With any given, a bundled wheel whose license the +ids do not allow fails the check, and so does one that declares a license only by name. + +Before the schema check, ``validate`` counts the components an SBOM lists, nested ones included, and fails an SBOM that +lists more than 5,000 without checking it further. The schema's ``uniqueItems`` rule compares every pair of items in an +array, so its time grows with the square of the count: 5,000 components in one array take about ten seconds to check, +10,000 about forty, and a document crafted that way could stall a CI job. Every file of the wheel, of each bundled wheel +and of each build tool counts, so a project with a large build environment can come close. Raise the cap for an SBOM +you expect to be larger: + +.. code-block:: console + + $ python-sbom validate dist/ --max-components 10000 diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..354f4a2 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,29 @@ +"""Write python-sbom's own SBOM into its wheel with the hook the package ships.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class SelfSbomHook(BuildHookInterface): # simplint: ignore[SMP409] # hatchling loads it from this script + def finalize( + self, + version: str, + build_data: dict[str, Any], # simplint: ignore[SMP602] # hatchling's signature + artifact_path: str, + ) -> None: + # python-sbom cannot be a build requirement of itself, so its hook comes from this source tree; by now the vcs + # hook has written the version module the package imports + sys.path.insert(0, str(Path(self.root) / "src")) + from python_sbom.hatch import SbomBuildHook # ruff: ignore[import-outside-top-level] # see above + + hook = SbomBuildHook( + self.root, self.config, self.build_config, self.metadata, self.directory, self.target_name, self.app + ) + # as hatchling names a custom hook, so the SBOM lists this script among the build tools + hook.PLUGIN_NAME = self.PLUGIN_NAME + hook.finalize(version, build_data, artifact_path) diff --git a/pylock.toml b/pylock.toml new file mode 100644 index 0000000..b04a1ac --- /dev/null +++ b/pylock.toml @@ -0,0 +1,3614 @@ +lock-version = "1.0" +environments = [ + "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"x86_64\" and implementation_name == \"cpython\" and platform_python_implementation == \"CPython\" and platform_system == \"Linux\"", +] +requires-python = ">=3.11" +extras = [ + "hatch", + "validate", +] +dependency-groups = [ + "build", + "dev", + "docs", + "lint", + "pkg-meta", + "project-runtime", + "release-news", + "release-notes", + "release-prepare", + "test", + "type", +] +default-groups = [ + "project-runtime", +] +created-by = "nab" + +[[packages]] +name = "accessible-pygments" +version = "0.0.5" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "accessible_pygments-0.0.5.tar.gz" +upload-time = 2024-05-10 11:23:10.216775+00:00 +url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz" +size = 1377899 + +[packages.sdist.hashes] +sha256 = "40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872" + +[[packages.wheels]] +name = "accessible_pygments-0.0.5-py3-none-any.whl" +upload-time = 2024-05-10 11:23:08.421378+00:00 +url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl" +size = 1395903 + +[packages.wheels.hashes] +sha256 = "88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7" + +[[packages]] +name = "alabaster" +version = "1.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "alabaster-1.0.0.tar.gz" +upload-time = 2024-07-26 18:15:03.762569+00:00 +url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz" +size = 24210 + +[packages.sdist.hashes] +sha256 = "c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e" + +[[packages.wheels]] +name = "alabaster-1.0.0-py3-none-any.whl" +upload-time = 2024-07-26 18:15:02.050654+00:00 +url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl" +size = 13929 + +[packages.wheels.hashes] +sha256 = "fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" + +[[packages]] +name = "annotated-types" +version = "0.8.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "annotated_types-0.8.0.tar.gz" +upload-time = 2026-07-23 20:16:13.995706+00:00 +url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz" +size = 15893 + +[packages.sdist.hashes] +sha256 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" + +[[packages.wheels]] +name = "annotated_types-0.8.0-py3-none-any.whl" +upload-time = 2026-07-23 20:16:12.938651+00:00 +url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl" +size = 13427 + +[packages.wheels.hashes] +sha256 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" + +[[packages]] +name = "arrow" +version = "1.4.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.8" +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "arrow-1.4.0.tar.gz" +upload-time = 2025-10-18 17:46:46.761994+00:00 +url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz" +size = 152931 + +[packages.sdist.hashes] +sha256 = "ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7" + +[[packages.wheels]] +name = "arrow-1.4.0-py3-none-any.whl" +upload-time = 2025-10-18 17:46:45.663184+00:00 +url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl" +size = 68797 + +[packages.wheels.hashes] +sha256 = "749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205" + +[[packages]] +name = "attrs" +version = "26.1.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "attrs-26.1.0.tar.gz" +upload-time = 2026-03-19 14:22:25.026315+00:00 +url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz" +size = 952055 + +[packages.sdist.hashes] +sha256 = "d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" + +[[packages.wheels]] +name = "attrs-26.1.0-py3-none-any.whl" +upload-time = 2026-03-19 14:22:23.645947+00:00 +url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl" +size = 67548 + +[packages.wheels.hashes] +sha256 = "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" + +[[packages]] +name = "babel" +version = "2.18.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "babel-2.18.0.tar.gz" +upload-time = 2026-02-01 12:30:56.078194+00:00 +url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz" +size = 9959554 + +[packages.sdist.hashes] +sha256 = "b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d" + +[[packages.wheels]] +name = "babel-2.18.0-py3-none-any.whl" +upload-time = 2026-02-01 12:30:53.445172+00:00 +url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl" +size = 10196845 + +[packages.wheels.hashes] +sha256 = "e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35" + +[[packages]] +name = "beartype" +version = "0.23.1" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "beartype-0.23.1.tar.gz" +upload-time = 2026-10-11 05:36:55.251880+00:00 +url = "https://files.pythonhosted.org/packages/79/34/c0cfdaee9c796c8381439fed0fcab62810753b8b0adb1ec88fa7f41eaa5d/beartype-0.23.1.tar.gz" +size = 1993714 + +[packages.sdist.hashes] +sha256 = "8b805f246b32931c74f2b321ea0939fe5bb2ff43c12a3eff19cc4c854b832e1d" + +[[packages.wheels]] +name = "beartype-0.23.1-py3-none-any.whl" +upload-time = 2026-10-11 05:36:52.431648+00:00 +url = "https://files.pythonhosted.org/packages/1c/cb/2f6aa982e2860c1156830db3da7f823b71784ed7d533ca289ea9b6c6347a/beartype-0.23.1-py3-none-any.whl" +size = 1582101 + +[packages.wheels.hashes] +sha256 = "4461b4dc57e3fdd6c8a8464b22cf118002eea107c33e9fef651c69068ab3cce3" + +[[packages]] +name = "beautifulsoup4" +version = "4.15.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7.0" +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "beautifulsoup4-4.15.0.tar.gz" +upload-time = 2026-06-07 16:44:20.453113+00:00 +url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz" +size = 632571 + +[packages.sdist.hashes] +sha256 = "288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7" + +[[packages.wheels]] +name = "beautifulsoup4-4.15.0-py3-none-any.whl" +upload-time = 2026-06-07 16:44:21.566528+00:00 +url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl" +size = 109924 + +[packages.wheels.hashes] +sha256 = "d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9" + +[[packages]] +name = "boolean-py" +version = "5.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "boolean_py-5.0.tar.gz" +upload-time = 2025-04-03 10:39:49.734090+00:00 +url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz" +size = 37047 + +[packages.sdist.hashes] +sha256 = "60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95" + +[[packages.wheels]] +name = "boolean_py-5.0-py3-none-any.whl" +upload-time = 2025-04-03 10:39:48.449265+00:00 +url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl" +size = 26577 + +[packages.wheels.hashes] +sha256 = "ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9" + +[[packages]] +name = "certifi" +version = "2026.10.23" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "certifi-2026.10.23.tar.gz" +upload-time = 2026-10-23 11:36:16.478386+00:00 +url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz" +size = 136225 + +[packages.sdist.hashes] +sha256 = "a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd" + +[[packages.wheels]] +name = "certifi-2026.10.23-py3-none-any.whl" +upload-time = 2026-10-23 11:36:14.763067+00:00 +url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl" +size = 135082 + +[packages.wheels.hashes] +sha256 = "e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1" + +[[packages]] +name = "cffi" +version = "2.1.1" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pycparser" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cffi-2.1.1.tar.gz" +upload-time = 2026-08-03 21:21:18.939988+00:00 +url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" +size = 530807 + +[packages.sdist.hashes] +sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" + +[[packages.wheels]] +name = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-08-03 21:20:57.336082+00:00 +url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 221630 + +[packages.wheels.hashes] +sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" + +[[packages]] +name = "cfgv" +version = "3.5.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cfgv-3.5.0.tar.gz" +upload-time = 2025-11-19 20:55:51.612035+00:00 +url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz" +size = 7334 + +[packages.sdist.hashes] +sha256 = "d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132" + +[[packages.wheels]] +name = "cfgv-3.5.0-py2.py3-none-any.whl" +upload-time = 2025-11-19 20:55:50.744331+00:00 +url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl" +size = 7445 + +[packages.wheels.hashes] +sha256 = "a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0" + +[[packages]] +name = "charset-normalizer" +version = "3.5.2" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "charset_normalizer-3.5.2.tar.gz" +upload-time = 2026-09-30 04:39:23.398219+00:00 +url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz" +size = 172659 + +[packages.sdist.hashes] +sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 04:37:37.397915+00:00 +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" +size = 257336 + +[packages.wheels.hashes] +sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +upload-time = 2026-09-30 04:38:28.032254+00:00 +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" +size = 253276 + +[packages.wheels.hashes] +sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-py3-none-any.whl" +upload-time = 2026-09-30 04:39:21.828049+00:00 +url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl" +size = 68872 + +[packages.wheels.hashes] +sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" + +[[packages]] +name = "check-wheel-contents" +version = "0.6.3" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wheel-filename" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "check_wheel_contents-0.6.3.tar.gz" +upload-time = 2025-08-02 14:01:45.546952+00:00 +url = "https://files.pythonhosted.org/packages/95/37/2b9e0d38c2f668791ffe8b97711185c364d91c027e47f189c371c2348d18/check_wheel_contents-0.6.3.tar.gz" +size = 586023 + +[packages.sdist.hashes] +sha256 = "10e6939e2fe4e6ce1edf2ff6ec6157808677e80782e78021ae139dd88473a442" + +[[packages.wheels]] +name = "check_wheel_contents-0.6.3-py3-none-any.whl" +upload-time = 2025-08-02 14:01:43.968164+00:00 +url = "https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl" +size = 27541 + +[packages.wheels.hashes] +sha256 = "5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874" + +[[packages]] +name = "click" +version = "8.5.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "click-8.5.0.tar.gz" +upload-time = 2026-08-26 13:33:14.560531+00:00 +url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" +size = 382235 + +[packages.sdist.hashes] +sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" + +[[packages.wheels]] +name = "click-8.5.0-py3-none-any.whl" +upload-time = 2026-08-26 13:33:12.928043+00:00 +url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" +size = 125251 + +[packages.wheels.hashes] +sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" + +[[packages]] +name = "covdefaults" +version = "2.3.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "coverage" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "covdefaults-2.3.0.tar.gz" +upload-time = 2023-03-05 16:43:34.779146+00:00 +url = "https://files.pythonhosted.org/packages/44/ee/9a6f2611f72e4c5657ae5542a510cf4164d2c673687c0ea73bb1cbd85b4d/covdefaults-2.3.0.tar.gz" +size = 4835 + +[packages.sdist.hashes] +sha256 = "4e99f679f12d792bc62e5510fa3eb59546ed47bd569e36e4fddc4081c9c3ebf7" + +[[packages.wheels]] +name = "covdefaults-2.3.0-py2.py3-none-any.whl" +upload-time = 2023-03-05 16:43:33.245581+00:00 +url = "https://files.pythonhosted.org/packages/76/4c/823bc951445aa97e5a1b7e337690db3abf85212c8d138e170922e7916ac8/covdefaults-2.3.0-py2.py3-none-any.whl" +size = 5144 + +[packages.wheels.hashes] +sha256 = "2832961f6ffcfe4b57c338bc3418a3526f495c26fb9c54565409c5532f7c41be" + +[[packages]] +name = "coverage" +version = "7.17.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "coverage-7.17.0.tar.gz" +upload-time = 2026-10-22 16:06:50.009670+00:00 +url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz" +size = 991021 + +[packages.sdist.hashes] +sha256 = "cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712" + +[[packages.wheels]] +name = "coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +upload-time = 2026-10-22 16:06:08.861264+00:00 +url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +size = 261735 + +[packages.wheels.hashes] +sha256 = "aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c" + +[[packages.wheels]] +name = "coverage-7.17.0-py3-none-any.whl" +upload-time = 2026-10-22 16:06:48.471847+00:00 +url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl" +size = 217383 + +[packages.wheels.hashes] +sha256 = "bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d" + +[[packages]] +name = "cryptography" +version = "50.0.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = "!=3.9.0,!=3.9.1,>=3.9" +dependencies = [ + { name = "cffi" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cryptography-50.0.2.tar.gz" +upload-time = 2026-09-30 15:30:04.884601+00:00 +url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz" +size = 880623 + +[packages.sdist.hashes] +sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:43:49.010540+00:00 +url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4719841 + +[packages.wheels.hashes] +sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:43:55.272150+00:00 +url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +size = 4753050 + +[packages.wheels.hashes] +sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:44:04.545137+00:00 +url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +size = 4752576 + +[packages.wheels.hashes] +sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:44:45.769143+00:00 +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" +size = 4966465 + +[packages.wheels.hashes] +sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:44:53.379605+00:00 +url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +size = 5001199 + +[packages.wheels.hashes] +sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:45:05.009031+00:00 +url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +size = 5000859 + +[packages.wheels.hashes] +sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 15:29:26.807001+00:00 +url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4749145 + +[packages.wheels.hashes] +sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 15:29:32.605292+00:00 +url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +size = 4781387 + +[packages.wheels.hashes] +sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 15:29:41.251208+00:00 +url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +size = 4780973 + +[packages.wheels.hashes] +sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" + +[[packages]] +name = "cyclonedx-python-lib" +version = "11.12.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "<4.0,>=3.9" +dependencies = [ + { name = "jsonschema" }, + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "referencing" }, + { name = "sortedcontainers" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cyclonedx_python_lib-11.12.0.tar.gz" +upload-time = 2026-08-13 07:52:18.675291+00:00 +url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz" +size = 1445378 + +[packages.sdist.hashes] +sha256 = "16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf" + +[[packages.wheels]] +name = "cyclonedx_python_lib-11.12.0-py3-none-any.whl" +upload-time = 2026-08-13 07:52:16.836824+00:00 +url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl" +size = 529453 + +[packages.wheels.hashes] +sha256 = "0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54" + +[[packages]] +name = "defusedxml" +version = "0.7.1" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "defusedxml-0.7.1.tar.gz" +upload-time = 2021-03-08 10:59:26.269532+00:00 +url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz" +size = 75520 + +[packages.sdist.hashes] +sha256 = "1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69" + +[[packages.wheels]] +name = "defusedxml-0.7.1-py2.py3-none-any.whl" +upload-time = 2021-03-08 10:59:24.450195+00:00 +url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl" +size = 25604 + +[packages.wheels.hashes] +sha256 = "a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + +[[packages]] +name = "distlib" +version = "0.4.4" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "distlib-0.4.4.tar.gz" +upload-time = 2026-10-16 13:56:34.203008+00:00 +url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz" +size = 621304 + +[packages.sdist.hashes] +sha256 = "ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d" + +[[packages.wheels]] +name = "distlib-0.4.4-py2.py3-none-any.whl" +upload-time = 2026-10-16 13:56:32.053304+00:00 +url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl" +size = 472099 + +[packages.wheels.hashes] +sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" + +[[packages]] +name = "docutils" +version = "0.23" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "docutils-0.23.tar.gz" +upload-time = 2026-05-27 17:41:06.934109+00:00 +url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" +size = 2303823 + +[packages.sdist.hashes] +sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" + +[[packages.wheels]] +name = "docutils-0.23-py3-none-any.whl" +upload-time = 2026-05-27 17:40:58.442528+00:00 +url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" +size = 634701 + +[packages.wheels.hashes] +sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" + +[[packages]] +name = "filelock" +version = "4.2.5" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "filelock-4.2.5.tar.gz" +upload-time = 2026-10-30 19:27:22.237276+00:00 +url = "https://files.pythonhosted.org/packages/aa/4b/2320d9fa03b65b4beb43b521fe1b12b7f87ab91ee42de3a286075016d205/filelock-4.2.5.tar.gz" +size = 659943 + +[packages.sdist.hashes] +sha256 = "37d8fa4f8b5e9233612e68ab23bf64cae41771322c8797361088ae2895b12894" + +[[packages.wheels]] +name = "filelock-4.2.5-py3-none-any.whl" +upload-time = 2026-10-30 19:27:20.409828+00:00 +url = "https://files.pythonhosted.org/packages/14/2d/2cf36eca330b7a97c0e9076e8fc4272127c29398fa2bb7b24d4321b8b476/filelock-4.2.5-py3-none-any.whl" +size = 163877 + +[packages.wheels.hashes] +sha256 = "46ce42d27e5cdde09ee7197349663e97cb106679bd9e75db3a09679fbc7aa1a8" + +[[packages]] +name = "fqdn" +version = "1.6.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<4,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "fqdn-1.6.0.tar.gz" +upload-time = 2026-09-28 05:43:35.482270+00:00 +url = "https://files.pythonhosted.org/packages/02/01/248d44fbda9e78fa1a7b5a475194bef4c4a3e285db286419f3bb42f2efdf/fqdn-1.6.0.tar.gz" +size = 130737 + +[packages.sdist.hashes] +sha256 = "e39bf62e2a9481aa2cb780554fdfd944dd8ee5f230eb831684d539cbafed8de2" + +[[packages.wheels]] +name = "fqdn-1.6.0-py3-none-any.whl" +upload-time = 2026-09-28 05:43:33.951821+00:00 +url = "https://files.pythonhosted.org/packages/15/d5/a8f9149b97cced9e1f4bff92ff0b6206120e2c853ae774d7020d6ecd8fa8/fqdn-1.6.0-py3-none-any.whl" +size = 12569 + +[packages.wheels.hashes] +sha256 = "6c793c312ccfa29981e1135b3ef5e1277ebe639731a6b0f7350d90e96e63f0b4" + +[[packages]] +name = "furo" +version = "2025.12.19" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "furo-2025.12.19.tar.gz" +upload-time = 2025-12-19 17:34:40.889109+00:00 +url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz" +size = 1661473 + +[packages.sdist.hashes] +sha256 = "188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7" + +[[packages.wheels]] +name = "furo-2025.12.19-py3-none-any.whl" +upload-time = 2025-12-19 17:34:38.905692+00:00 +url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl" +size = 339262 + +[packages.wheels.hashes] +sha256 = "bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f" + +[[packages]] +name = "hatch-fancy-pypi-readme" +version = "25.1.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "hatchling" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "hatch_fancy_pypi_readme-25.1.0.tar.gz" +upload-time = 2025-05-01 10:34:42.989334+00:00 +url = "https://files.pythonhosted.org/packages/f3/0f/aed57c301f339936eb91cb4d8c1e5088a101081854bd3ec18a889df32365/hatch_fancy_pypi_readme-25.1.0.tar.gz" +size = 29701 + +[packages.sdist.hashes] +sha256 = "9c58ed3dff90d51f43414ce37009ad1d5b0f08ffc9fc216998a06380f01c0045" + +[[packages.wheels]] +name = "hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" +upload-time = 2025-05-01 10:34:41.204793+00:00 +url = "https://files.pythonhosted.org/packages/58/b4/ad71e051b34f1935783de37beffee16e343ff2514b204dc515f9e939d4af/hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" +size = 10046 + +[packages.wheels.hashes] +sha256 = "ce0134c40d63d874ac48f48ccc678b8f3b62b8e50e9318520d2bffc752eedaf3" + +[[packages]] +name = "hatch-vcs" +version = "0.5.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "hatchling" }, + { name = "setuptools-scm" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "hatch_vcs-0.5.0.tar.gz" +upload-time = 2025-05-27 05:16:04.490926+00:00 +url = "https://files.pythonhosted.org/packages/6b/b0/4cc743d38adbee9d57d786fa496ed1daadb17e48589b6da8fa55717a0746/hatch_vcs-0.5.0.tar.gz" +size = 11424 + +[packages.sdist.hashes] +sha256 = "0395fa126940340215090c344a2bf4e2a77bcbe7daab16f41b37b98c95809ff9" + +[[packages.wheels]] +name = "hatch_vcs-0.5.0-py3-none-any.whl" +upload-time = 2025-05-27 05:16:03.184909+00:00 +url = "https://files.pythonhosted.org/packages/5f/48/1f85cee4b7b4f40b9b814b1febbc661bda6ced9649e410a0b74f6e415dd0/hatch_vcs-0.5.0-py3-none-any.whl" +size = 8507 + +[packages.wheels.hashes] +sha256 = "b49677dbdc597460cc22d01b27ab3696f5e16a21ecf2700fb01bc28e1f2a04a7" + +[[packages]] +name = "hatchling" +version = "1.33.0" +marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.10" +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomlkit" }, + { name = "trove-classifiers" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "hatchling-1.33.0.tar.gz" +upload-time = 2026-10-28 02:27:30.694770+00:00 +url = "https://files.pythonhosted.org/packages/7a/f1/e47b937226ae3b093bbd0132eb0b7ee5bfad12c2b5324afd10ae49946b05/hatchling-1.33.0.tar.gz" +size = 58079 + +[packages.sdist.hashes] +sha256 = "4c422bd4d554fcff5de5b73e9596f85e41e9f97f7e553d8f7595ecb31a627bef" + +[[packages.wheels]] +name = "hatchling-1.33.0-py3-none-any.whl" +upload-time = 2026-10-28 02:27:29.033037+00:00 +url = "https://files.pythonhosted.org/packages/32/64/4afe5dd77b2939a93e8c9513f8082dbd049ecba9c1484a379a0f6ae29d6b/hatchling-1.33.0-py3-none-any.whl" +size = 78750 + +[packages.wheels.hashes] +sha256 = "415290b84f464d2884303fca8974d38e7b85579069fab02a15db0828fbb1446d" + +[[packages]] +name = "id" +version = "1.6.1" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "id-1.6.1.tar.gz" +upload-time = 2026-02-04 16:19:41.260133+00:00 +url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz" +size = 18088 + +[packages.sdist.hashes] +sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" + +[[packages.wheels]] +name = "id-1.6.1-py3-none-any.whl" +upload-time = 2026-02-04 16:19:40.051745+00:00 +url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl" +size = 14689 + +[packages.wheels.hashes] +sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca" + +[[packages]] +name = "identify" +version = "2.6.20" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "identify-2.6.20.tar.gz" +upload-time = 2026-09-26 20:29:07.187608+00:00 +url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz" +size = 99681 + +[packages.sdist.hashes] +sha256 = "ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285" + +[[packages.wheels]] +name = "identify-2.6.20-py2.py3-none-any.whl" +upload-time = 2026-09-26 20:29:06.054092+00:00 +url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl" +size = 99506 + +[packages.wheels.hashes] +sha256 = "6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa" + +[[packages]] +name = "idna" +version = "3.20" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "idna-3.20.tar.gz" +upload-time = 2026-09-17 14:11:04.752193+00:00 +url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" +size = 216463 + +[packages.sdist.hashes] +sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" + +[[packages.wheels]] +name = "idna-3.20-py3-none-any.whl" +upload-time = 2026-09-17 14:11:03.168153+00:00 +url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" +size = 69583 + +[packages.wheels.hashes] +sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" + +[[packages]] +name = "imagesize" +version = "2.0.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "imagesize-2.0.1.tar.gz" +upload-time = 2026-08-24 12:35:19.130667+00:00 +url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz" +size = 1883774 + +[packages.sdist.hashes] +sha256 = "b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5" + +[[packages.wheels]] +name = "imagesize-2.0.1-py3-none-any.whl" +upload-time = 2026-08-24 12:35:12.548956+00:00 +url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl" +size = 14794 + +[packages.wheels.hashes] +sha256 = "ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02" + +[[packages]] +name = "iniconfig" +version = "2.3.1" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "iniconfig-2.3.1.tar.gz" +upload-time = 2026-10-06 22:48:38.076390+00:00 +url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" +size = 21209 + +[packages.sdist.hashes] +sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" + +[[packages.wheels]] +name = "iniconfig-2.3.1-py3-none-any.whl" +upload-time = 2026-10-06 22:48:36.959685+00:00 +url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" +size = 7552 + +[packages.wheels.hashes] +sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" + +[[packages]] +name = "installer" +version = "1.1.0" +marker = "\"dev\" in dependency_groups or \"project-runtime\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "installer-1.1.0.tar.gz" +upload-time = 2026-10-12 19:35:52.851170+00:00 +url = "https://files.pythonhosted.org/packages/56/d3/2fab33d15004d41ae8e7b5d3e310510f7f34a7e1dd032a3e60d8b38d8fba/installer-1.1.0.tar.gz" +size = 481257 + +[packages.sdist.hashes] +sha256 = "5adc0c01ce62d809989e246987cd68944fe5df8af562d0358f65b2ab15cb5de7" + +[[packages.wheels]] +name = "installer-1.1.0-py3-none-any.whl" +upload-time = 2026-10-12 19:35:51.096942+00:00 +url = "https://files.pythonhosted.org/packages/d7/7a/63547a792a12148882205d79e07644091d5b7690f40c2bb9d41452a8bdc2/installer-1.1.0-py3-none-any.whl" +size = 464466 + +[packages.wheels.hashes] +sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0" + +[[packages]] +name = "isoduration" +version = "20.11.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.7" +dependencies = [ + { name = "arrow" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "isoduration-20.11.0.tar.gz" +upload-time = 2020-11-01 11:00:00.312591+00:00 +url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz" +size = 11649 + +[packages.sdist.hashes] +sha256 = "ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9" + +[[packages.wheels]] +name = "isoduration-20.11.0-py3-none-any.whl" +upload-time = 2020-11-01 10:59:58.020369+00:00 +url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl" +size = 11321 + +[packages.wheels.hashes] +sha256 = "b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042" + +[[packages]] +name = "jaraco-classes" +version = "3.4.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "more-itertools" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jaraco.classes-3.4.0.tar.gz" +upload-time = 2024-03-31 07:27:36.643708+00:00 +url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz" +size = 11780 + +[packages.sdist.hashes] +sha256 = "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd" + +[[packages.wheels]] +name = "jaraco.classes-3.4.0-py3-none-any.whl" +upload-time = 2024-03-31 07:27:34.792832+00:00 +url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl" +size = 6777 + +[packages.wheels.hashes] +sha256 = "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + +[[packages]] +name = "jaraco-context" +version = "6.1.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jaraco_context-6.1.2.tar.gz" +upload-time = 2026-03-20 22:13:33.922545+00:00 +url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz" +size = 16801 + +[packages.sdist.hashes] +sha256 = "f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3" + +[[packages.wheels]] +name = "jaraco_context-6.1.2-py3-none-any.whl" +upload-time = 2026-03-20 22:13:32.808832+00:00 +url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl" +size = 7871 + +[packages.wheels.hashes] +sha256 = "bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" + +[[packages]] +name = "jaraco-functools" +version = "4.6.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "more-itertools" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jaraco_functools-4.6.0.tar.gz" +upload-time = 2026-07-14 01:28:02.544761+00:00 +url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz" +size = 20837 + +[packages.sdist.hashes] +sha256 = "880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" + +[[packages.wheels]] +name = "jaraco_functools-4.6.0-py3-none-any.whl" +upload-time = 2026-07-14 01:28:01.590470+00:00 +url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl" +size = 11677 + +[packages.wheels.hashes] +sha256 = "99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30" + +[[packages]] +name = "jeepney" +version = "0.9.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jeepney-0.9.0.tar.gz" +upload-time = 2025-02-27 18:51:01.684959+00:00 +url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz" +size = 106758 + +[packages.sdist.hashes] +sha256 = "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732" + +[[packages.wheels]] +name = "jeepney-0.9.0-py3-none-any.whl" +upload-time = 2025-02-27 18:51:00.104279+00:00 +url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl" +size = 49010 + +[packages.wheels.hashes] +sha256 = "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" + +[[packages]] +name = "jinja2" +version = "3.1.6" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "markupsafe" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jinja2-3.1.6.tar.gz" +upload-time = 2025-03-05 20:05:02.478302+00:00 +url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" +size = 245115 + +[packages.sdist.hashes] +sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" + +[[packages.wheels]] +name = "jinja2-3.1.6-py3-none-any.whl" +upload-time = 2025-03-05 20:05:00.369721+00:00 +url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" +size = 134899 + +[packages.wheels.hashes] +sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" + +[[packages]] +name = "jsonpointer" +version = "3.6" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jsonpointer-3.6.tar.gz" +upload-time = 2026-10-26 20:59:26.307086+00:00 +url = "https://files.pythonhosted.org/packages/54/04/e35b34f4e441473e62d82d6ed9a8f748d93d895f4c05d4e645aa0fde51d7/jsonpointer-3.6.tar.gz" +size = 14352 + +[packages.sdist.hashes] +sha256 = "82e839a3ba3a9e2b54394fda8b3d276c2459d80408d06ef9a6fbc369d7544ffc" + +[[packages.wheels]] +name = "jsonpointer-3.6-py3-none-any.whl" +upload-time = 2026-10-26 20:59:24.947043+00:00 +url = "https://files.pythonhosted.org/packages/6e/34/cee746fb30473bfaa92f19267fcc102a1118eaec83e7e2b5652a91ead01c/jsonpointer-3.6-py3-none-any.whl" +size = 9333 + +[packages.wheels.hashes] +sha256 = "a45c858f8a9e171f6b3790ed9446c9382782f6c4cb4589176813c5a553f4cfcd" + +[[packages]] +name = "jsonschema" +version = "4.26.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +dependencies = [ + { name = "attrs" }, + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "rpds-py" }, + { name = "uri-template" }, + { name = "webcolors" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jsonschema-4.26.0.tar.gz" +upload-time = 2026-01-07 13:41:07.246891+00:00 +url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz" +size = 366583 + +[packages.sdist.hashes] +sha256 = "0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" + +[[packages.wheels]] +name = "jsonschema-4.26.0-py3-none-any.whl" +upload-time = 2026-01-07 13:41:05.306692+00:00 +url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl" +size = 90630 + +[packages.wheels.hashes] +sha256 = "d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" + +[[packages]] +name = "jsonschema-specifications" +version = "2025.9.1" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +dependencies = [ + { name = "referencing" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jsonschema_specifications-2025.9.1.tar.gz" +upload-time = 2025-09-08 01:34:59.186960+00:00 +url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz" +size = 32855 + +[packages.sdist.hashes] +sha256 = "b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" + +[[packages.wheels]] +name = "jsonschema_specifications-2025.9.1-py3-none-any.whl" +upload-time = 2025-09-08 01:34:57.871954+00:00 +url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl" +size = 18437 + +[packages.wheels.hashes] +sha256 = "98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" + +[[packages]] +name = "keyring" +version = "25.7.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney" }, + { name = "secretstorage" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "keyring-25.7.0.tar.gz" +upload-time = 2025-11-16 16:26:09.482176+00:00 +url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz" +size = 63516 + +[packages.sdist.hashes] +sha256 = "fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b" + +[[packages.wheels]] +name = "keyring-25.7.0-py3-none-any.whl" +upload-time = 2025-11-16 16:26:08.402146+00:00 +url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl" +size = 39160 + +[packages.wheels.hashes] +sha256 = "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" + +[[packages]] +name = "lark" +version = "1.3.1" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "lark-1.3.1.tar.gz" +upload-time = 2025-10-27 18:25:56.653447+00:00 +url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz" +size = 382732 + +[packages.sdist.hashes] +sha256 = "b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905" + +[[packages.wheels]] +name = "lark-1.3.1-py3-none-any.whl" +upload-time = 2025-10-27 18:25:54.882451+00:00 +url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl" +size = 113151 + +[packages.wheels.hashes] +sha256 = "c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12" + +[[packages]] +name = "license-expression" +version = "30.4.4" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +dependencies = [ + { name = "boolean-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "license_expression-30.4.4.tar.gz" +upload-time = 2025-07-22 11:13:32.170576+00:00 +url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz" +size = 186402 + +[packages.sdist.hashes] +sha256 = "73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd" + +[[packages.wheels]] +name = "license_expression-30.4.4-py3-none-any.whl" +upload-time = 2025-07-22 11:13:31.217173+00:00 +url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl" +size = 120615 + +[packages.wheels.hashes] +sha256 = "421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4" + +[[packages]] +name = "markdown-it-py" +version = "4.2.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "mdurl" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markdown_it_py-4.2.0.tar.gz" +upload-time = 2026-05-07 12:08:28.360612+00:00 +url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" +size = 82454 + +[packages.sdist.hashes] +sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" + +[[packages.wheels]] +name = "markdown_it_py-4.2.0-py3-none-any.whl" +upload-time = 2026-05-07 12:08:27.182789+00:00 +url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" +size = 91687 + +[packages.wheels.hashes] +sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" + +[[packages]] +name = "markupsafe" +version = "3.0.4" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markupsafe-3.0.4.tar.gz" +upload-time = 2026-10-02 23:07:22.290270+00:00 +url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" +size = 153777 + +[packages.sdist.hashes] +sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" + +[[packages.wheels]] +name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-10-02 23:06:22.524416+00:00 +url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 23111 + +[packages.wheels.hashes] +sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" + +[[packages]] +name = "mdformat" +version = "1.0.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat-1.0.0.tar.gz" +upload-time = 2025-10-16 12:05:03.695033+00:00 +url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" +size = 56953 + +[packages.sdist.hashes] +sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" + +[[packages.wheels]] +name = "mdformat-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 12:05:02.607432+00:00 +url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" +size = 53288 + +[packages.wheels.hashes] +sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" + +[[packages]] +name = "mdformat-gfm" +version = "1.0.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat_gfm-1.0.0.tar.gz" +upload-time = 2025-10-16 09:12:22.402429+00:00 +url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" +size = 10112 + +[packages.sdist.hashes] +sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" + +[[packages.wheels]] +name = "mdformat_gfm-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 09:12:21.276157+00:00 +url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" +size = 10970 + +[packages.wheels.hashes] +sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" + +[[packages]] +name = "mdit-py-plugins" +version = "0.6.1" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdit_py_plugins-0.6.1.tar.gz" +upload-time = 2026-05-13 09:03:38.910230+00:00 +url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" +size = 56114 + +[packages.sdist.hashes] +sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" + +[[packages.wheels]] +name = "mdit_py_plugins-0.6.1-py3-none-any.whl" +upload-time = 2026-05-13 09:03:37.760000+00:00 +url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" +size = 66663 + +[packages.wheels.hashes] +sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" + +[[packages]] +name = "mdurl" +version = "0.1.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdurl-0.1.2.tar.gz" +upload-time = 2022-08-14 12:40:10.846281+00:00 +url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" +size = 8729 + +[packages.sdist.hashes] +sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + +[[packages.wheels]] +name = "mdurl-0.1.2-py3-none-any.whl" +upload-time = 2022-08-14 12:40:09.779676+00:00 +url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" +size = 9979 + +[packages.wheels.hashes] +sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" + +[[packages]] +name = "more-itertools" +version = "11.2.1" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "more_itertools-11.2.1.tar.gz" +upload-time = 2026-10-14 18:50:30.407709+00:00 +url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz" +size = 154133 + +[packages.sdist.hashes] +sha256 = "cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63" + +[[packages.wheels]] +name = "more_itertools-11.2.1-py3-none-any.whl" +upload-time = 2026-10-14 18:50:29.116732+00:00 +url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl" +size = 74405 + +[packages.wheels.hashes] +sha256 = "35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0" + +[[packages]] +name = "nh3" +version = "0.3.7" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nh3-0.3.7.tar.gz" +upload-time = 2026-08-23 14:26:30.728660+00:00 +url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz" +size = 25662 + +[packages.sdist.hashes] +sha256 = "71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848" + +[[packages.wheels]] +name = "nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-08-23 14:26:17.272038+00:00 +url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 832161 + +[packages.wheels.hashes] +sha256 = "70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42" + +[[packages]] +name = "nodeenv" +version = "1.11.1" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nodeenv-1.11.1.tar.gz" +upload-time = 2026-10-29 20:54:04.261005+00:00 +url = "https://files.pythonhosted.org/packages/ab/27/79acfd3d6f526a4a16f3d209dedcf7b5bc1ea15c5bf2bbb82f2c2285057e/nodeenv-1.11.1.tar.gz" +size = 99853 + +[packages.sdist.hashes] +sha256 = "d7fa027c1df437bdd974b0502a8dc76fa1b3183e369ac9539602bc6bab9100ba" + +[[packages.wheels]] +name = "nodeenv-1.11.1-py2.py3-none-any.whl" +upload-time = 2026-10-29 20:54:02.752512+00:00 +url = "https://files.pythonhosted.org/packages/40/35/a09e0b1dac4027e35b872e4f123a9f9e7bbc39e6424bdc9776920d3a6491/nodeenv-1.11.1-py2.py3-none-any.whl" +size = 34473 + +[packages.wheels.hashes] +sha256 = "bc0fb085d8ed0d67a6471892535f3d9b795eb911b7cd129d3a24967aab7a538f" + +[[packages]] +name = "packageurl-python" +version = "0.17.6" +marker = "\"dev\" in dependency_groups or \"project-runtime\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "packageurl_python-0.17.6.tar.gz" +upload-time = 2025-11-24 15:20:17.998933+00:00 +url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz" +size = 50618 + +[packages.sdist.hashes] +sha256 = "1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25" + +[[packages.wheels]] +name = "packageurl_python-0.17.6-py3-none-any.whl" +upload-time = 2025-11-24 15:20:16.962118+00:00 +url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl" +size = 36776 + +[packages.wheels.hashes] +sha256 = "31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9" + +[[packages]] +name = "packaging" +version = "26.3" +marker = "\"hatch\" in extras or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"lint\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"project-runtime\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "packaging-26.3.tar.gz" +upload-time = 2026-08-04 18:15:28.737071+00:00 +url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" +size = 313412 + +[packages.sdist.hashes] +sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" + +[[packages.wheels]] +name = "packaging-26.3-py3-none-any.whl" +upload-time = 2026-08-04 18:15:27.159957+00:00 +url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" +size = 129956 + +[packages.wheels.hashes] +sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" + +[[packages]] +name = "pathspec" +version = "1.1.1" +marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pathspec-1.1.1.tar.gz" +upload-time = 2026-04-27 01:46:08.907631+00:00 +url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz" +size = 135180 + +[packages.sdist.hashes] +sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" + +[[packages.wheels]] +name = "pathspec-1.1.1-py3-none-any.whl" +upload-time = 2026-04-27 01:46:07.060850+00:00 +url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" +size = 57328 + +[packages.wheels.hashes] +sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" + +[[packages]] +name = "platformdirs" +version = "4.14.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "platformdirs-4.14.0.tar.gz" +upload-time = 2026-10-28 18:27:30.973421+00:00 +url = "https://files.pythonhosted.org/packages/40/28/1c058a9143d2304b3078f8dadcbb9dc27f7d3581b5d05157c84869c4cc0d/platformdirs-4.14.0.tar.gz" +size = 70387 + +[packages.sdist.hashes] +sha256 = "83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b" + +[[packages.wheels]] +name = "platformdirs-4.14.0-py3-none-any.whl" +upload-time = 2026-10-28 18:27:29.659262+00:00 +url = "https://files.pythonhosted.org/packages/69/2b/887969ef4bb53e0cb271dc7c3f4a73d50c5d58bb88aeaae311e3340cd57e/platformdirs-4.14.0-py3-none-any.whl" +size = 35259 + +[packages.wheels.hashes] +sha256 = "1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91" + +[[packages]] +name = "pluggy" +version = "1.7.0" +marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pluggy-1.7.0.tar.gz" +upload-time = 2026-10-15 09:50:58.343484+00:00 +url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" +size = 123304 + +[packages.sdist.hashes] +sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" + +[[packages.wheels]] +name = "pluggy-1.7.0-py3-none-any.whl" +upload-time = 2026-10-15 09:50:56.808462+00:00 +url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" +size = 27082 + +[packages.wheels.hashes] +sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" + +[[packages]] +name = "ply" +version = "3.11" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "ply-3.11.tar.gz" +upload-time = 2018-02-15 19:01:31.097641+00:00 +url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz" +size = 159130 + +[packages.sdist.hashes] +sha256 = "00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3" + +[[packages.wheels]] +name = "ply-3.11-py2.py3-none-any.whl" +upload-time = 2018-02-15 19:01:27.172483+00:00 +url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl" +size = 49567 + +[packages.wheels.hashes] +sha256 = "096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce" + +[[packages]] +name = "pre-commit" +version = "4.7.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pre_commit-4.7.0.tar.gz" +upload-time = 2026-10-12 20:45:40.320969+00:00 +url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz" +size = 198120 + +[packages.sdist.hashes] +sha256 = "1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86" + +[[packages.wheels]] +name = "pre_commit-4.7.0-py2.py3-none-any.whl" +upload-time = 2026-10-12 20:45:38.858255+00:00 +url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl" +size = 226261 + +[packages.wheels.hashes] +sha256 = "2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877" + +[[packages]] +name = "pre-commit-uv" +version = "4.4.1" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "pre-commit" }, + { name = "uv" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pre_commit_uv-4.4.1.tar.gz" +upload-time = 2026-10-27 07:00:28.638309+00:00 +url = "https://files.pythonhosted.org/packages/98/62/2791f79c1cf48993ef0bee64e0636c9e86212467cdd3d0486a21d51f2438/pre_commit_uv-4.4.1.tar.gz" +size = 11474 + +[packages.sdist.hashes] +sha256 = "e59ce77dc49d0b0481efbc97293c4fe235ddeb5ab5028c8a9e20e6cb5cce9756" + +[[packages.wheels]] +name = "pre_commit_uv-4.4.1-py3-none-any.whl" +upload-time = 2026-10-27 07:00:27.254598+00:00 +url = "https://files.pythonhosted.org/packages/46/3d/ca2481fa9d5fd3c99e88be090dbadf5320dda8ddb80df13931e832ea46ef/pre_commit_uv-4.4.1-py3-none-any.whl" +size = 5525 + +[packages.wheels.hashes] +sha256 = "5c387a044d74c215090835c97bd41e469659025c6551ddc73f9f8e7d8d323c00" + +[[packages]] +name = "prek" +version = "0.5.9" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "prek-0.5.9.tar.gz" +upload-time = 2026-10-30 10:32:01.272337+00:00 +url = "https://files.pythonhosted.org/packages/f2/c6/89312171d4b87b7d2419a077f84e638bee69d1b7943598c72685655f86a7/prek-0.5.9.tar.gz" +size = 603716 + +[packages.sdist.hashes] +sha256 = "47bc660a7e959860a44afb5ff3dc167d96f04d929b405020a0f2c7683d3d00d8" + +[[packages.wheels]] +name = "prek-0.5.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-30 10:31:52.132595+00:00 +url = "https://files.pythonhosted.org/packages/7c/de/9f538470c56f0524215d5ede21fdb092b5a5a55ee8b4ccc88a35487d57e0/prek-0.5.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 5942047 + +[packages.wheels.hashes] +sha256 = "1ef04fd7b4bae3a31e1180bc87e877a3a5584063fe79320a125267afee47090d" + +[[packages]] +name = "py-serializable" +version = "2.1.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "<4.0,>=3.8" +dependencies = [ + { name = "defusedxml" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "py_serializable-2.1.0.tar.gz" +upload-time = 2025-07-21 09:56:48.070686+00:00 +url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz" +size = 52368 + +[packages.sdist.hashes] +sha256 = "9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103" + +[[packages.wheels]] +name = "py_serializable-2.1.0-py3-none-any.whl" +upload-time = 2025-07-21 09:56:46.848289+00:00 +url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl" +size = 23045 + +[packages.wheels.hashes] +sha256 = "b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304" + +[[packages]] +name = "pycparser" +version = "3.12" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pycparser-3.12.tar.gz" +upload-time = 2026-10-22 13:40:03.560254+00:00 +url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz" +size = 158785 + +[packages.sdist.hashes] +sha256 = "f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6" + +[[packages.wheels]] +name = "pycparser-3.12-py3-none-any.whl" +upload-time = 2026-10-22 13:40:02.021539+00:00 +url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl" +size = 62600 + +[packages.wheels.hashes] +sha256 = "a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e" + +[[packages]] +name = "pydantic" +version = "2.14.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pydantic-2.14.2.tar.gz" +upload-time = 2026-10-29 16:37:12.476407+00:00 +url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz" +size = 911596 + +[packages.sdist.hashes] +sha256 = "fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d" + +[[packages.wheels]] +name = "pydantic-2.14.2-py3-none-any.whl" +upload-time = 2026-10-29 16:37:10.181777+00:00 +url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl" +size = 490403 + +[packages.wheels.hashes] +sha256 = "7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118" + +[[packages]] +name = "pydantic-core" +version = "2.50.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pydantic_core-2.50.2.tar.gz" +upload-time = 2026-10-29 16:34:57.465908+00:00 +url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz" +size = 518711 + +[packages.sdist.hashes] +sha256 = "1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775" + +[[packages.wheels]] +name = "pydantic_core-2.50.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-29 16:33:53.333286+00:00 +url = "https://files.pythonhosted.org/packages/e6/d0/dd0e355694094f0045922f4da60ac9bc7a314babc494e46089fb00640fd3/pydantic_core-2.50.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 2249999 + +[packages.wheels.hashes] +sha256 = "5299296ff5cafcb69851fd652de8f9268a0bb036bcbedbfcaa94cf743baf3986" + +[[packages]] +name = "pygments" +version = "2.21.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pygments-2.21.0.tar.gz" +upload-time = 2026-08-17 08:02:48.824391+00:00 +url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" +size = 5005329 + +[packages.sdist.hashes] +sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" + +[[packages.wheels]] +name = "pygments-2.21.0-py3-none-any.whl" +upload-time = 2026-08-17 08:02:44.912148+00:00 +url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" +size = 1250147 + +[packages.wheels.hashes] +sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" + +[[packages]] +name = "pyparsing" +version = "3.3.3" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyparsing-3.3.3.tar.gz" +upload-time = 2026-09-20 20:59:05.609622+00:00 +url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz" +size = 1050487 + +[packages.sdist.hashes] +sha256 = "928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36" + +[[packages.wheels]] +name = "pyparsing-3.3.3-py3-none-any.whl" +upload-time = 2026-09-20 20:59:04.025856+00:00 +url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl" +size = 126420 + +[packages.wheels.hashes] +sha256 = "ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4" + +[[packages]] +name = "pytest" +version = "9.2.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest-9.2.0.tar.gz" +upload-time = 2026-10-27 21:30:20.728655+00:00 +url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz" +size = 1797555 + +[packages.sdist.hashes] +sha256 = "97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b" + +[[packages.wheels]] +name = "pytest-9.2.0-py3-none-any.whl" +upload-time = 2026-10-27 21:30:19.131148+00:00 +url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl" +size = 411776 + +[packages.wheels.hashes] +sha256 = "07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588" + +[[packages]] +name = "pytest-cov" +version = "7.1.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest_cov-7.1.0.tar.gz" +upload-time = 2026-03-21 20:11:16.284275+00:00 +url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz" +size = 55592 + +[packages.sdist.hashes] +sha256 = "30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" + +[[packages.wheels]] +name = "pytest_cov-7.1.0-py3-none-any.whl" +upload-time = 2026-03-21 20:11:14.438324+00:00 +url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl" +size = 22876 + +[packages.wheels.hashes] +sha256 = "a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" + +[[packages]] +name = "pytest-mock" +version = "3.17.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pytest" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest_mock-3.17.0.tar.gz" +upload-time = 2026-10-17 16:21:42.435701+00:00 +url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz" +size = 43064 + +[packages.sdist.hashes] +sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" + +[[packages.wheels]] +name = "pytest_mock-3.17.0-py3-none-any.whl" +upload-time = 2026-10-17 16:21:41.265482+00:00 +url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl" +size = 11248 + +[packages.wheels.hashes] +sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" + +[[packages]] +name = "python-dateutil" +version = "3.0.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +dependencies = [ + { name = "six" }, + { name = "tzdata" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "python_dateutil-3.0.0.tar.gz" +upload-time = 2026-10-21 23:41:45.075347+00:00 +url = "https://files.pythonhosted.org/packages/0e/df/f15f80383f152452d8985b36d95cb8751f901a3e0ff4cd27f7e118f168e2/python_dateutil-3.0.0.tar.gz" +size = 271066 + +[packages.sdist.hashes] +sha256 = "7ebcb3c29a92f3ceafe79a7a48a5c45677e9e004bbeec5742ac0bbb00560b7a2" + +[[packages.wheels]] +name = "python_dateutil-3.0.0-py2.py3-none-any.whl" +upload-time = 2026-10-21 23:41:43.431814+00:00 +url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl" +size = 94659 + +[packages.wheels.hashes] +sha256 = "3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd" + +[[packages]] +name = "python-discovery" +version = "1.9.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "filelock" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "python_discovery-1.9.0.tar.gz" +upload-time = 2026-10-30 23:24:24.910342+00:00 +url = "https://files.pythonhosted.org/packages/86/15/0405206e0cfe0c14b91ac038d31f30d1dd78139b3b2a285e6be41bf6e5af/python_discovery-1.9.0.tar.gz" +size = 125151 + +[packages.sdist.hashes] +sha256 = "88beb57e51b919c30f303b053ec41359298ca349192035b0a802c76347ae96d7" + +[[packages.wheels]] +name = "python_discovery-1.9.0-py3-none-any.whl" +upload-time = 2026-10-30 23:24:23.452190+00:00 +url = "https://files.pythonhosted.org/packages/cb/3c/da1cae963a076132dbc4d3c1f0292dd2f373b9d3e072d42f4620801159c1/python_discovery-1.9.0-py3-none-any.whl" +size = 39841 + +[packages.wheels.hashes] +sha256 = "fb65edfff3541b7ceb3a39be334a0f69f4cb5148287a053e1874748742fda93b" + +[[packages]] +name = "pyyaml" +version = "6.0.3" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"lint\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyyaml-6.0.3.tar.gz" +upload-time = 2025-09-25 21:33:16.546886+00:00 +url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" +size = 130960 + +[packages.sdist.hashes] +sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" + +[[packages]] +name = "rdflib" +version = "7.6.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.8.1" +dependencies = [ + { name = "pyparsing" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rdflib-7.6.0.tar.gz" +upload-time = 2026-02-13 07:15:55.938955+00:00 +url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz" +size = 4943826 + +[packages.sdist.hashes] +sha256 = "6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df" + +[[packages.wheels]] +name = "rdflib-7.6.0-py3-none-any.whl" +upload-time = 2026-02-13 07:15:46.487762+00:00 +url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl" +size = 615416 + +[packages.wheels.hashes] +sha256 = "30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd" + +[[packages]] +name = "readme-renderer" +version = "46.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "readme_renderer-46.0.tar.gz" +upload-time = 2026-08-28 15:18:32.490778+00:00 +url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz" +size = 38382 + +[packages.sdist.hashes] +sha256 = "af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90" + +[[packages.wheels]] +name = "readme_renderer-46.0-py3-none-any.whl" +upload-time = 2026-08-28 15:18:31.132970+00:00 +url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl" +size = 14239 + +[packages.wheels.hashes] +sha256 = "d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560" + +[[packages]] +name = "referencing" +version = "0.37.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "referencing-0.37.0.tar.gz" +upload-time = 2025-10-13 15:30:48.871335+00:00 +url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz" +size = 78036 + +[packages.sdist.hashes] +sha256 = "44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" + +[[packages.wheels]] +name = "referencing-0.37.0-py3-none-any.whl" +upload-time = 2025-10-13 15:30:47.625937+00:00 +url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl" +size = 26766 + +[packages.wheels.hashes] +sha256 = "381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" + +[[packages]] +name = "release-creator" +version = "4.4.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.15" +dependencies = [ + { name = "docutils" }, + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdformat-gfm" }, + { name = "packaging" }, + { name = "towncrier" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "release_creator-4.4.0.tar.gz" +upload-time = 2026-11-02 21:44:48.475063+00:00 +url = "https://files.pythonhosted.org/packages/d8/cf/8f376cc925c26fa5434d327ba03f4ed709b80d2077edda865fc3b2fe1940/release_creator-4.4.0.tar.gz" +size = 171450 + +[packages.sdist.hashes] +sha256 = "263d518ddee6371fa13d25bd07f7b5af0fa7a6e421b16f1e8143b0a9443bc84d" + +[[packages.wheels]] +name = "release_creator-4.4.0-py3-none-any.whl" +upload-time = 2026-11-02 21:44:47.195366+00:00 +url = "https://files.pythonhosted.org/packages/7b/29/6f2a3158b082b3bf8a94eb93662d80b4db4212abea0fc1fae07ac9a043da/release_creator-4.4.0-py3-none-any.whl" +size = 54784 + +[packages.wheels.hashes] +sha256 = "560ed208cca0dc53bc227878e5596e3e26e44fa277658c5d0dc662901d95f8cb" + +[[packages]] +name = "requests" +version = "2.34.2" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "requests-2.34.2.tar.gz" +upload-time = 2026-05-14 19:25:27.735762+00:00 +url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" +size = 142856 + +[packages.sdist.hashes] +sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" + +[[packages.wheels]] +name = "requests-2.34.2-py3-none-any.whl" +upload-time = 2026-05-14 19:25:26.443000+00:00 +url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl" +size = 73075 + +[packages.wheels.hashes] +sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" + +[[packages]] +name = "requests-toolbelt" +version = "1.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +dependencies = [ + { name = "requests" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "requests-toolbelt-1.0.0.tar.gz" +upload-time = 2023-05-01 04:11:33.229998+00:00 +url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz" +size = 206888 + +[packages.sdist.hashes] +sha256 = "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" + +[[packages.wheels]] +name = "requests_toolbelt-1.0.0-py2.py3-none-any.whl" +upload-time = 2023-05-01 04:11:28.427086+00:00 +url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl" +size = 54481 + +[packages.wheels.hashes] +sha256 = "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + +[[packages]] +name = "rfc3339-validator" +version = "0.1.4" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +dependencies = [ + { name = "six" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc3339_validator-0.1.4.tar.gz" +upload-time = 2021-05-12 16:37:54.178122+00:00 +url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz" +size = 5513 + +[packages.sdist.hashes] +sha256 = "138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b" + +[[packages.wheels]] +name = "rfc3339_validator-0.1.4-py2.py3-none-any.whl" +upload-time = 2021-05-12 16:37:52.536885+00:00 +url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl" +size = 3490 + +[packages.wheels.hashes] +sha256 = "24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa" + +[[packages]] +name = "rfc3986" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc3986-2.0.0.tar.gz" +upload-time = 2022-01-10 00:52:30.832978+00:00 +url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz" +size = 49026 + +[packages.sdist.hashes] +sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + +[[packages.wheels]] +name = "rfc3986-2.0.0-py2.py3-none-any.whl" +upload-time = 2022-01-10 00:52:29.594625+00:00 +url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl" +size = 31326 + +[packages.wheels.hashes] +sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" + +[[packages]] +name = "rfc3986-validator" +version = "0.1.1" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc3986_validator-0.1.1.tar.gz" +upload-time = 2019-10-28 16:00:19.144483+00:00 +url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz" +size = 6760 + +[packages.sdist.hashes] +sha256 = "3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055" + +[[packages.wheels]] +name = "rfc3986_validator-0.1.1-py2.py3-none-any.whl" +upload-time = 2019-10-28 16:00:13.976195+00:00 +url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl" +size = 4242 + +[packages.wheels.hashes] +sha256 = "2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9" + +[[packages]] +name = "rfc3987-syntax" +version = "1.1.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +dependencies = [ + { name = "lark" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc3987_syntax-1.1.0.tar.gz" +upload-time = 2025-07-18 01:05:05.015375+00:00 +url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz" +size = 14239 + +[packages.sdist.hashes] +sha256 = "717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d" + +[[packages.wheels]] +name = "rfc3987_syntax-1.1.0-py3-none-any.whl" +upload-time = 2025-07-18 01:05:03.843605+00:00 +url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl" +size = 8046 + +[packages.wheels.hashes] +sha256 = "6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f" + +[[packages]] +name = "rich" +version = "15.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.9.0" +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rich-15.0.0.tar.gz" +upload-time = 2026-04-12 08:24:00.750018+00:00 +url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz" +size = 230680 + +[packages.sdist.hashes] +sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" + +[[packages.wheels]] +name = "rich-15.0.0-py3-none-any.whl" +upload-time = 2026-04-12 08:24:02.830783+00:00 +url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl" +size = 310654 + +[packages.wheels.hashes] +sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" + +[[packages]] +name = "roman-numerals" +version = "4.1.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "roman_numerals-4.1.0.tar.gz" +upload-time = 2025-12-17 18:25:34.381818+00:00 +url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz" +size = 9077 + +[packages.sdist.hashes] +sha256 = "1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2" + +[[packages.wheels]] +name = "roman_numerals-4.1.0-py3-none-any.whl" +upload-time = 2025-12-17 18:25:33.098993+00:00 +url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl" +size = 7676 + +[packages.wheels.hashes] +sha256 = "647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7" + +[[packages]] +name = "rpds-py" +version = "2026.9.1" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rpds_py-2026.9.1.tar.gz" +upload-time = 2026-10-04 16:32:36.469775+00:00 +url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz" +size = 63948 + +[packages.sdist.hashes] +sha256 = "4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12" + +[[packages.wheels]] +name = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-04 16:30:58.295504+00:00 +url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 372022 + +[packages.wheels.hashes] +sha256 = "2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945" + +[[packages]] +name = "secretstorage" +version = "3.5.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "secretstorage-3.5.0.tar.gz" +upload-time = 2025-11-23 19:02:53.191898+00:00 +url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz" +size = 19884 + +[packages.sdist.hashes] +sha256 = "f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be" + +[[packages.wheels]] +name = "secretstorage-3.5.0-py3-none-any.whl" +upload-time = 2025-11-23 19:02:51.545472+00:00 +url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl" +size = 15554 + +[packages.wheels.hashes] +sha256 = "0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" + +[[packages]] +name = "semantic-version" +version = "2.10.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "semantic_version-2.10.0.tar.gz" +upload-time = 2022-05-26 13:35:23.454764+00:00 +url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz" +size = 52289 + +[packages.sdist.hashes] +sha256 = "bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c" + +[[packages.wheels]] +name = "semantic_version-2.10.0-py2.py3-none-any.whl" +upload-time = 2022-05-26 13:35:21.206388+00:00 +url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl" +size = 15552 + +[packages.wheels.hashes] +sha256 = "de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177" + +[[packages]] +name = "setuptools" +version = "84.2.1" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "setuptools-84.2.1.tar.gz" +upload-time = 2026-10-20 19:01:37.742699+00:00 +url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz" +size = 1230289 + +[packages.sdist.hashes] +sha256 = "3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc" + +[[packages.wheels]] +name = "setuptools-84.2.1-py3-none-any.whl" +upload-time = 2026-10-20 19:01:35.678013+00:00 +url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl" +size = 876263 + +[packages.wheels.hashes] +sha256 = "ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2" + +[[packages]] +name = "setuptools-scm" +version = "10.3.4" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, + { name = "vcs-versioning" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "setuptools_scm-10.3.4.tar.gz" +upload-time = 2026-09-23 19:21:42.950372+00:00 +url = "https://files.pythonhosted.org/packages/85/d8/fc143f88819ccf10ba2388ba86732ee2de193e578234e25a783f6cc14bf7/setuptools_scm-10.3.4.tar.gz" +size = 78410 + +[packages.sdist.hashes] +sha256 = "a69f28bfc245608781205e912faae437c2b2165773afa4e7b979d77447a69dd2" + +[[packages.wheels]] +name = "setuptools_scm-10.3.4-py3-none-any.whl" +upload-time = 2026-09-23 19:21:41.607429+00:00 +url = "https://files.pythonhosted.org/packages/da/f5/54538a1f17ea753c42b0928dc2e62986d463f8c393340b9a1e602f2d4dd9/setuptools_scm-10.3.4-py3-none-any.whl" +size = 33366 + +[packages.wheels.hashes] +sha256 = "82f34c3e3084fc2b57d397200637cc13f2338004052d2fee47baa5f0e902464e" + +[[packages]] +name = "six" +version = "1.17.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "six-1.17.0.tar.gz" +upload-time = 2024-12-04 17:35:28.174320+00:00 +url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz" +size = 34031 + +[packages.sdist.hashes] +sha256 = "ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" + +[[packages.wheels]] +name = "six-1.17.0-py2.py3-none-any.whl" +upload-time = 2024-12-04 17:35:26.475808+00:00 +url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl" +size = 11050 + +[packages.wheels.hashes] +sha256 = "4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" + +[[packages]] +name = "snowballstemmer" +version = "3.1.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.3" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "snowballstemmer-3.1.1.tar.gz" +upload-time = 2026-06-03 00:56:40.194915+00:00 +url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz" +size = 123314 + +[packages.sdist.hashes] +sha256 = "e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260" + +[[packages.wheels]] +name = "snowballstemmer-3.1.1-py3-none-any.whl" +upload-time = 2026-06-03 00:56:38.614300+00:00 +url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl" +size = 104164 + +[packages.wheels.hashes] +sha256 = "7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752" + +[[packages]] +name = "sortedcontainers" +version = "2.4.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sortedcontainers-2.4.0.tar.gz" +upload-time = 2021-05-16 22:03:42.897871+00:00 +url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz" +size = 30594 + +[packages.sdist.hashes] +sha256 = "25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88" + +[[packages.wheels]] +name = "sortedcontainers-2.4.0-py2.py3-none-any.whl" +upload-time = 2021-05-16 22:03:41.177807+00:00 +url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl" +size = 29575 + +[packages.wheels.hashes] +sha256 = "a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" + +[[packages]] +name = "soupsieve" +version = "3.0.4" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.11.5" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "soupsieve-3.0.4.tar.gz" +upload-time = 2026-10-22 00:45:26.362373+00:00 +url = "https://files.pythonhosted.org/packages/19/63/0b6bae54546aed98d72f12d09df57b19a287f01c84d22047fa2e23772ab9/soupsieve-3.0.4.tar.gz" +size = 119017 + +[packages.sdist.hashes] +sha256 = "924fab84ae21604c19e175e1f39815eb6fa83ba23836e09e9de180925e5bc4c0" + +[[packages.wheels]] +name = "soupsieve-3.0.4-py3-none-any.whl" +upload-time = 2026-10-22 00:45:24.843035+00:00 +url = "https://files.pythonhosted.org/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl" +size = 42458 + +[packages.wheels.hashes] +sha256 = "8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf" + +[[packages]] +name = "spdx-tools" +version = "0.8.5" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +dependencies = [ + { name = "beartype" }, + { name = "click" }, + { name = "license-expression" }, + { name = "ply" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "semantic-version" }, + { name = "uritools" }, + { name = "xmltodict" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "spdx_tools-0.8.5.tar.gz" +upload-time = 2026-03-13 09:29:23.155398+00:00 +url = "https://files.pythonhosted.org/packages/2f/99/33383f587b59cbd191cc1c0fd5408e550b9275e0090d32561cbddc973fdc/spdx_tools-0.8.5.tar.gz" +size = 696323 + +[packages.sdist.hashes] +sha256 = "be600beb2f762f0116025e05490d399e724f668bef84025a7c421bb266688bdb" + +[[packages.wheels]] +name = "spdx_tools-0.8.5-py3-none-any.whl" +upload-time = 2026-03-13 09:29:20.907842+00:00 +url = "https://files.pythonhosted.org/packages/88/f5/1ac402e1f8d253fa60ac167c4a1fb7eeb0f29a04942788e2d43c984cd749/spdx_tools-0.8.5-py3-none-any.whl" +size = 286573 + +[packages.wheels.hashes] +sha256 = "7c2d5865941be9d2e898f5b084e8d5422dd298dc5a29320ddb198fec304f59c4" + +[[packages]] +name = "sphinx" +version = "9.2.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.13" +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx-9.2.0.tar.gz" +upload-time = 2026-10-21 00:05:15.019079+00:00 +url = "https://files.pythonhosted.org/packages/64/d4/413466f0be8b1cb0e3e43893a148b96d7462d0923d82b4166b21afc5f30c/sphinx-9.2.0.tar.gz" +size = 8782438 + +[packages.sdist.hashes] +sha256 = "90470459f3cab15d9dfed146c71cf77694b0445df17b2e0e69059cb9f7fc9ee4" + +[[packages.wheels]] +name = "sphinx-9.2.0-py3-none-any.whl" +upload-time = 2026-10-21 00:05:12.241734+00:00 +url = "https://files.pythonhosted.org/packages/c4/28/a0c50761cd215b7042f4bc617a6bc5c0cd7be62f75bc908e169e28a4d21e/sphinx-9.2.0-py3-none-any.whl" +size = 3925388 + +[packages.wheels.hashes] +sha256 = "4c3ec13b612712c0d3ce4cbdd9e3eb91e68577e07a8e31f32f21cf178453578c" + +[[packages]] +name = "sphinx-argparse-cli" +version = "1.24.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx_argparse_cli-1.24.1.tar.gz" +upload-time = 2026-10-27 07:02:09.017213+00:00 +url = "https://files.pythonhosted.org/packages/ec/83/5502c61d82db593198adcb3a376505b767f0f464ebf2db50253cf3c7f319/sphinx_argparse_cli-1.24.1.tar.gz" +size = 17808 + +[packages.sdist.hashes] +sha256 = "9adc72739eb3b099f5de94d8f60af031aaef8355f57398d33ff3e6ea848abd00" + +[[packages.wheels]] +name = "sphinx_argparse_cli-1.24.1-py3-none-any.whl" +upload-time = 2026-10-27 07:02:07.884391+00:00 +url = "https://files.pythonhosted.org/packages/51/c6/16119ac3a8b54b3e92ad668bc6e787cbc79353e7f177f247d4e3f74b3de2/sphinx_argparse_cli-1.24.1-py3-none-any.whl" +size = 12299 + +[packages.wheels.hashes] +sha256 = "6bc2e83f4ecf7c110a91a526ca239465fef6d457c0665d89d5e0a7dc3eb71c73" + +[[packages]] +name = "sphinx-autodoc-typehints" +version = "3.17.4" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.12" +dependencies = [ + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx_autodoc_typehints-3.17.4.tar.gz" +upload-time = 2026-10-27 19:18:44.225219+00:00 +url = "https://files.pythonhosted.org/packages/9e/a9/cfe13ff89678c6d310a8a9060333b7823bae1dbce9615c7032be89aaec20/sphinx_autodoc_typehints-3.17.4.tar.gz" +size = 511398 + +[packages.sdist.hashes] +sha256 = "a5ddd545192380292f13dd9fc232717a0925fbc66617bc51b3db1e0480db0fec" + +[[packages.wheels]] +name = "sphinx_autodoc_typehints-3.17.4-py3-none-any.whl" +upload-time = 2026-10-27 19:18:42.674074+00:00 +url = "https://files.pythonhosted.org/packages/fa/66/40004fe2aea60edc4800328599dd115b3058f79bf148be1746349281f308/sphinx_autodoc_typehints-3.17.4-py3-none-any.whl" +size = 140603 + +[packages.wheels.hashes] +sha256 = "251271a49e46573c2e752bcc92dae232def822a2c9983272cb04d282eadc2bfa" + +[[packages]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx_basic_ng-1.0.0b2.tar.gz" +upload-time = 2023-07-08 18:40:54.166146+00:00 +url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz" +size = 20736 + +[packages.sdist.hashes] +sha256 = "9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9" + +[[packages.wheels]] +name = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl" +upload-time = 2023-07-08 18:40:52.659879+00:00 +url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl" +size = 22496 + +[packages.wheels.hashes] +sha256 = "eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b" + +[[packages]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_applehelp-2.0.0.tar.gz" +upload-time = 2024-07-29 01:09:00.465812+00:00 +url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz" +size = 20053 + +[packages.sdist.hashes] +sha256 = "2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1" + +[[packages.wheels]] +name = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:08:58.990722+00:00 +url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" +size = 119300 + +[packages.wheels.hashes] +sha256 = "4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5" + +[[packages]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_devhelp-2.0.0.tar.gz" +upload-time = 2024-07-29 01:09:23.417467+00:00 +url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz" +size = 12967 + +[packages.sdist.hashes] +sha256 = "411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad" + +[[packages.wheels]] +name = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:09:21.945229+00:00 +url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" +size = 82530 + +[packages.wheels.hashes] +sha256 = "aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2" + +[[packages]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_htmlhelp-2.1.0.tar.gz" +upload-time = 2024-07-29 01:09:37.889318+00:00 +url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz" +size = 22617 + +[packages.sdist.hashes] +sha256 = "c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9" + +[[packages.wheels]] +name = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" +upload-time = 2024-07-29 01:09:36.407072+00:00 +url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" +size = 98705 + +[packages.wheels.hashes] +sha256 = "166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8" + +[[packages]] +name = "sphinxcontrib-jsmath" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_jsmath-2.0.0.tar.gz" +upload-time = 2026-10-20 18:22:05.514380+00:00 +url = "https://files.pythonhosted.org/packages/59/02/4f334c7fc3e54c51d2364575a41cd2fae6847e7f53001949f0be5f32e18b/sphinxcontrib_jsmath-2.0.0.tar.gz" +size = 14511 + +[packages.sdist.hashes] +sha256 = "551f780cf9687f9c3c74f0cf9f18aca06e0d6958853453f3803fe4d357c5b924" + +[[packages.wheels]] +name = "sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" +upload-time = 2026-10-20 18:22:04.420584+00:00 +url = "https://files.pythonhosted.org/packages/da/c2/2965c35b39587d4b7a397f65a9c7d4b942b031061e7ac1ce91782f394a0d/sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" +size = 78095 + +[packages.wheels.hashes] +sha256 = "15ca92fe0eec82f0baf91a7efcc51ffacdb605924be6327683a52ebdacb6744c" + +[[packages]] +name = "sphinxcontrib-mermaid" +version = "2.1.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_mermaid-2.1.1.tar.gz" +upload-time = 2026-09-01 16:10:23.671939+00:00 +url = "https://files.pythonhosted.org/packages/8c/87/06537dff20aa57df2d0f2afa87edff4afa85dec6f57f03f7acc24f83ff88/sphinxcontrib_mermaid-2.1.1.tar.gz" +size = 22353 + +[packages.sdist.hashes] +sha256 = "f8d7f1b2f0ad69b7ebdd5cbf532fb4ee107893b5373bc2d0e9634089c0b2cfee" + +[[packages.wheels]] +name = "sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" +upload-time = 2026-09-01 16:10:22.377054+00:00 +url = "https://files.pythonhosted.org/packages/50/6a/5bf2222b8084c7a7bc3ec23d470c91e3e03e82e4ce0bbacaa61f456ce7c1/sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" +size = 16368 + +[packages.wheels.hashes] +sha256 = "3b9ac1a8e0b5154dec3c0acf9b5c54dd9ba58540e416f20858c300814c25648f" + +[[packages]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_qthelp-2.0.0.tar.gz" +upload-time = 2024-07-29 01:09:56.435212+00:00 +url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz" +size = 17165 + +[packages.sdist.hashes] +sha256 = "4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab" + +[[packages.wheels]] +name = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:09:54.885300+00:00 +url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" +size = 88743 + +[packages.wheels.hashes] +sha256 = "b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb" + +[[packages]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_serializinghtml-2.0.0.tar.gz" +upload-time = 2024-07-29 01:10:09.332393+00:00 +url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz" +size = 16080 + +[packages.sdist.hashes] +sha256 = "e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d" + +[[packages.wheels]] +name = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:10:08.203690+00:00 +url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" +size = 92072 + +[packages.wheels.hashes] +sha256 = "6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331" + +[[packages]] +name = "tomlkit" +version = "0.15.2" +marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tomlkit-0.15.2.tar.gz" +upload-time = 2026-10-16 04:07:08.166827+00:00 +url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" +size = 190522 + +[packages.sdist.hashes] +sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" + +[[packages.wheels]] +name = "tomlkit-0.15.2-py3-none-any.whl" +upload-time = 2026-10-16 04:07:09.216014+00:00 +url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" +size = 52225 + +[packages.wheels.hashes] +sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" + +[[packages]] +name = "towncrier" +version = "26.9.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "towncrier-26.9.0.tar.gz" +upload-time = 2026-09-04 12:57:25.341701+00:00 +url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" +size = 79338 + +[packages.sdist.hashes] +sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" + +[[packages.wheels]] +name = "towncrier-26.9.0-py3-none-any.whl" +upload-time = 2026-09-04 12:57:23.986160+00:00 +url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" +size = 67030 + +[packages.wheels.hashes] +sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" + +[[packages]] +name = "trove-classifiers" +version = "2026.9.21.13" +marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "trove_classifiers-2026.9.21.13.tar.gz" +upload-time = 2026-09-21 13:29:07.301512+00:00 +url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz" +size = 17085 + +[packages.sdist.hashes] +sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" + +[[packages.wheels]] +name = "trove_classifiers-2026.9.21.13-py3-none-any.whl" +upload-time = 2026-09-21 13:29:06.123578+00:00 +url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl" +size = 14232 + +[packages.wheels.hashes] +sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" + +[[packages]] +name = "twine" +version = "7.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "id" }, + { name = "keyring" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "twine-7.0.0.tar.gz" +upload-time = 2026-07-27 15:59:00.825945+00:00 +url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz" +size = 215032 + +[packages.sdist.hashes] +sha256 = "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177" + +[[packages.wheels]] +name = "twine-7.0.0-py3-none-any.whl" +upload-time = 2026-07-27 15:58:59.260418+00:00 +url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl" +size = 43204 + +[packages.wheels.hashes] +sha256 = "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" + +[[packages]] +name = "ty" +version = "0.0.95" +marker = "\"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "ty-0.0.95.tar.gz" +upload-time = 2026-10-29 19:21:51.955948+00:00 +url = "https://files.pythonhosted.org/packages/39/08/d63954788578b9189dbf85081d8cbdba9a32a9a6a29729129a0da09cd3a0/ty-0.0.95.tar.gz" +size = 8115525 + +[packages.sdist.hashes] +sha256 = "a480853a74d43074b2d4b28025d406b9d7223f7fe9f3a6c2adec3eacdbf14ca0" + +[[packages.wheels]] +name = "ty-0.0.95-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-29 19:21:30.801674+00:00 +url = "https://files.pythonhosted.org/packages/90/64/636308717989afa2fe765604ae2a76189f470d566e5e58da4482b625d64e/ty-0.0.95-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 16312046 + +[packages.wheels.hashes] +sha256 = "6d47be2db5db80cd70c582b240a71dd8e9be215ce9288298c863c7c3611ef02b" + +[[packages]] +name = "typing-extensions" +version = "4.16.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "typing_extensions-4.16.0.tar.gz" +upload-time = 2026-07-02 08:40:05.920538+00:00 +url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz" +size = 113555 + +[packages.sdist.hashes] +sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" + +[[packages.wheels]] +name = "typing_extensions-4.16.0-py3-none-any.whl" +upload-time = 2026-07-02 08:40:04.659120+00:00 +url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl" +size = 45571 + +[packages.wheels.hashes] +sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" + +[[packages]] +name = "typing-inspection" +version = "0.4.4" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "typing_inspection-0.4.4.tar.gz" +upload-time = 2026-08-12 12:37:25.997329+00:00 +url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz" +size = 76928 + +[packages.sdist.hashes] +sha256 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" + +[[packages.wheels]] +name = "typing_inspection-0.4.4-py3-none-any.whl" +upload-time = 2026-08-12 12:37:24.648475+00:00 +url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl" +size = 14750 + +[packages.wheels.hashes] +sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" + +[[packages]] +name = "tzdata" +version = "2026.6" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=2" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tzdata-2026.6.tar.gz" +upload-time = 2026-10-28 09:57:09.137703+00:00 +url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz" +size = 201017 + +[packages.sdist.hashes] +sha256 = "85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d" + +[[packages.wheels]] +name = "tzdata-2026.6-py2.py3-none-any.whl" +upload-time = 2026-10-28 09:57:07.276552+00:00 +url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl" +size = 348210 + +[packages.wheels.hashes] +sha256 = "0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec" + +[[packages]] +name = "uri-template" +version = "1.3.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "uri-template-1.3.0.tar.gz" +upload-time = 2023-06-21 01:49:05.374755+00:00 +url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz" +size = 21678 + +[packages.sdist.hashes] +sha256 = "0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7" + +[[packages.wheels]] +name = "uri_template-1.3.0-py3-none-any.whl" +upload-time = 2023-06-21 01:49:03.467606+00:00 +url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl" +size = 11140 + +[packages.wheels.hashes] +sha256 = "a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363" + +[[packages]] +name = "uritools" +version = "6.1.3" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "uritools-6.1.3.tar.gz" +upload-time = 2026-07-23 23:17:44.612642+00:00 +url = "https://files.pythonhosted.org/packages/a3/0d/20d02264b6682f07e92cbf7ee43e5e803670d101a03ef204ba18368c321f/uritools-6.1.3.tar.gz" +size = 23909 + +[packages.sdist.hashes] +sha256 = "3a498e7e85ef3249343d5710618d641a414da0fbae6d23053ada7976ee83ea5f" + +[[packages.wheels]] +name = "uritools-6.1.3-py3-none-any.whl" +upload-time = 2026-07-23 23:17:43.275613+00:00 +url = "https://files.pythonhosted.org/packages/ac/76/e9bb35862bcc5b82218d2c017b1eacdcfeb3ba46ce0c0ca8ffc4db80ddbe/uritools-6.1.3-py3-none-any.whl" +size = 11981 + +[packages.wheels.hashes] +sha256 = "136f113e76e53f85bf2d9cce0c3d40ceec775d0409e7d6de9b28f046a7d42838" + +[[packages]] +name = "urllib3" +version = "2.8.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "urllib3-2.8.0.tar.gz" +upload-time = 2026-09-15 19:29:36.253420+00:00 +url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz" +size = 458972 + +[packages.sdist.hashes] +sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" + +[[packages.wheels]] +name = "urllib3-2.8.0-py3-none-any.whl" +upload-time = 2026-09-15 19:29:34.577402+00:00 +url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl" +size = 135717 + +[packages.wheels.hashes] +sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" + +[[packages]] +name = "uv" +version = "0.13.6" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "uv-0.13.6.tar.gz" +upload-time = 2026-10-27 00:45:57.605614+00:00 +url = "https://files.pythonhosted.org/packages/08/d5/88d43310d425f6e3a43997ad771658ded3237d0ed78c16a89b2353eddf56/uv-0.13.6.tar.gz" +size = 7545242 + +[packages.sdist.hashes] +sha256 = "d762215478854f6cf20c3faed8f1eb30c8101d568e7bc32a3abafc063929d5c5" + +[[packages.wheels]] +name = "uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-27 00:45:31.291019+00:00 +url = "https://files.pythonhosted.org/packages/5c/43/0688ff5142934238f2812e348f4a24e09b57d48bfb38dd8f850325a0d9de/uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 17333810 + +[packages.wheels.hashes] +sha256 = "22759b8895e1103578de6fb4cd27e94b4399d021023378d29f01333717751fba" + +[[packages]] +name = "vcs-versioning" +version = "2.6.0" +marker = "\"build\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "packaging" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "vcs_versioning-2.6.0.tar.gz" +upload-time = 2026-10-06 22:21:55.024903+00:00 +url = "https://files.pythonhosted.org/packages/ad/e7/2a691db7d076b75d099fdba1a14cd966931f2aeb591e77f6d22f15097326/vcs_versioning-2.6.0.tar.gz" +size = 166800 + +[packages.sdist.hashes] +sha256 = "22e9159288e2d8bca2fa6f6c31c34c9c66aec157f60e9e526f5d160d2516d60f" + +[[packages.wheels]] +name = "vcs_versioning-2.6.0-py3-none-any.whl" +upload-time = 2026-10-06 22:21:53.267565+00:00 +url = "https://files.pythonhosted.org/packages/73/1d/ad4064bf12f4503bf61e65d74c93c523672776d25ee0a06aaf42ae09ef3f/vcs_versioning-2.6.0-py3-none-any.whl" +size = 123154 + +[packages.wheels.hashes] +sha256 = "f8bebd1302ebc12e67ceb30fc7dd698ec969414b6fa43b30c3f14ac9b1e72c41" + +[[packages]] +name = "virtualenv" +version = "21.15.2" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "virtualenv-21.15.2.tar.gz" +upload-time = 2026-10-30 19:18:04.759827+00:00 +url = "https://files.pythonhosted.org/packages/38/9e/40c7098240cedd3a2f32e42a74b35c4563c617fdd7e41ae6268d4fdd8915/virtualenv-21.15.2.tar.gz" +size = 5520597 + +[packages.sdist.hashes] +sha256 = "dbd886e6e5689eb4dde6a9bbc478ad14b4fc8ed0852690d8273ccfb8c467f635" + +[[packages.wheels]] +name = "virtualenv-21.15.2-py3-none-any.whl" +upload-time = 2026-10-30 19:18:01.134751+00:00 +url = "https://files.pythonhosted.org/packages/f7/d8/09c9d6df253b2016312dd5ccbb962dfa86a5202f2452604b52e0a9844661/virtualenv-21.15.2-py3-none-any.whl" +size = 5559530 + +[packages.wheels.hashes] +sha256 = "fb8c62c7c328e0238061c721068f06b7bc6bd10f7a0f5987195cef1008837ee6" + +[[packages]] +name = "wcwidth" +version = "0.9.4" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "wcwidth-0.9.4.tar.gz" +upload-time = 2026-10-24 18:11:44.954051+00:00 +url = "https://files.pythonhosted.org/packages/ea/01/e5ac23733628a6bbb91913a765fcf55ee8070d8d991bc88fdaaf805052c7/wcwidth-0.9.4.tar.gz" +size = 970051 + +[packages.sdist.hashes] +sha256 = "fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954" + +[[packages.wheels]] +name = "wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-24 18:11:18.748328+00:00 +url = "https://files.pythonhosted.org/packages/fa/e8/0485850748dfdba9443f4151c7b2442cfa4987ba6cd871038eb5d6b8eca9/wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 785946 + +[packages.wheels.hashes] +sha256 = "b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2" + +[[packages.wheels]] +name = "wcwidth-0.9.4-py3-none-any.whl" +upload-time = 2026-10-24 18:10:52.651030+00:00 +url = "https://files.pythonhosted.org/packages/6f/0c/309b1813b230d1dbbd6f2c2ab2bb21ef68992358977605d9ef289bbfedcc/wcwidth-0.9.4-py3-none-any.whl" +size = 301806 + +[packages.wheels.hashes] +sha256 = "901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d" + +[[packages]] +name = "webcolors" +version = "25.10.0" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "webcolors-25.10.0.tar.gz" +upload-time = 2025-10-31 07:51:03.977878+00:00 +url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz" +size = 53491 + +[packages.sdist.hashes] +sha256 = "62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf" + +[[packages.wheels]] +name = "webcolors-25.10.0-py3-none-any.whl" +upload-time = 2025-10-31 07:51:01.778455+00:00 +url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl" +size = 14905 + +[packages.wheels.hashes] +sha256 = "032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d" + +[[packages]] +name = "wheel-filename" +version = "1.4.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "wheel_filename-1.4.2.tar.gz" +upload-time = 2024-12-01 13:03:16.012739+00:00 +url = "https://files.pythonhosted.org/packages/38/be/726dab762b770d0417e505c58e26d661aac1ec0c831e483cda4817ca2417/wheel_filename-1.4.2.tar.gz" +size = 7911 + +[packages.sdist.hashes] +sha256 = "87891c465dcbb40b40394a906f01a93214bdd51aa5d25e3a9a59cae62bc298fd" + +[[packages.wheels]] +name = "wheel_filename-1.4.2-py3-none-any.whl" +upload-time = 2024-12-01 13:03:00.536117+00:00 +url = "https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl" +size = 6195 + +[packages.wheels.hashes] +sha256 = "3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3" + +[[packages]] +name = "xmltodict" +version = "1.0.4" +marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "xmltodict-1.0.4.tar.gz" +upload-time = 2026-02-22 02:21:22.074263+00:00 +url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz" +size = 26124 + +[packages.sdist.hashes] +sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61" + +[[packages.wheels]] +name = "xmltodict-1.0.4-py3-none-any.whl" +upload-time = 2026-02-22 02:21:21.039836+00:00 +url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl" +size = 13580 + +[packages.wheels.hashes] +sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" + +[[packages]] +name = "zipremove" +version = "0.10.0" +marker = "\"project-runtime\" in dependency_groups" +requires-python = "~=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "zipremove-0.10.0.tar.gz" +upload-time = 2026-07-17 15:50:45.516180+00:00 +url = "https://files.pythonhosted.org/packages/92/30/47d65b9213f52df8a51f603cb0a2e5ef02c22172a8a17805e46c3beabb0d/zipremove-0.10.0.tar.gz" +size = 25448 + +[packages.sdist.hashes] +sha256 = "3c5957c115447b54299d3a23128764b59c4558fc98c4b878ff2dbc5db8a62021" + +[[packages.wheels]] +name = "zipremove-0.10.0-py3-none-any.whl" +upload-time = 2026-07-17 15:50:44.653422+00:00 +url = "https://files.pythonhosted.org/packages/0a/e4/2225c1890ce6e239ca23efabb80a3481229a14a2deaa489f5e89723a5aa6/zipremove-0.10.0-py3-none-any.whl" +size = 11280 + +[packages.wheels.hashes] +sha256 = "04e15df7083642d5b791e51f597cffef8a92d770ecf5160c529b5a7b53b40100" + +[tool.nab] +nab-version = "0.0.23" +created-at = 2026-11-07 05:12:05.951651+00:00 +command-line = [ + "nab", + "lock", + "--all-groups", + "--all-extras", + "--no-cache", +] +input-path = "pyproject.toml" +mode = "universal" +python-specifier = ">=3.15,<3.16" +platforms = [ + "linux_x86_64", +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..33c67a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,260 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ + "hatch-fancy-pypi-readme>=25.1", + "hatch-vcs>=0.5", + "hatchling>=1.32.4", + "installer>=1.1", + "packageurl-python>=0.11.1", + "zipremove>=0.10", +] + +[project] +name = "python-sbom" +import-names = [ "python_sbom" ] +description = "SBOMs for Python distributions: write PEP 770 CycloneDX SBOMs into wheels." +keywords = [ + "cyclonedx", + "hatch", + "pep770", + "sbom", + "spdx", +] +license = "MIT" +license-files = [ "LICENSE" ] +maintainers = [ + { name = "Bernรกt Gรกbor", email = "gaborjbernat@gmail.com" }, +] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Hatch", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Security", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dynamic = [ + "readme", + "version", +] +dependencies = [ + "installer>=1.1", + "packageurl-python>=0.11.1", + "packaging>=26.3", + "zipremove>=0.10", +] +optional-dependencies.hatch = [ + "hatchling>=1.28", +] +optional-dependencies.validate = [ + "cyclonedx-python-lib[json-validation]>=11.12", + "license-expression>=30", + "spdx-tools>=0.8.5", +] +urls.Changelog = "https://python-sbom.readthedocs.io/en/latest/changelog.html" +urls.Documentation = "https://python-sbom.readthedocs.io" +urls.Homepage = "https://github.com/tox-dev/python-sbom" +urls.Source = "https://github.com/tox-dev/python-sbom" +urls.Tracker = "https://github.com/tox-dev/python-sbom/issues" +scripts.python-sbom = "python_sbom._cli._main:main" +entry-points.hatch.python-sbom = "python_sbom.hatch.hooks" + +[dependency-groups] +dev = [ + { include-group = "lint" }, + { include-group = "pkg-meta" }, + { include-group = "test" }, + { include-group = "type" }, +] +test = [ + "covdefaults>=2.3", + "cyclonedx-python-lib[json-validation]>=11.12", + "hatchling>=1.28", + "installer>=1.1", + "pytest>=9.1.1", + "pytest-cov>=7.1", + "pytest-mock>=3.16", + "spdx-tools>=0.8.5", +] +type = [ + "ty>=0.0.84", + { include-group = "docs" }, + { include-group = "test" }, +] +docs = [ + "furo>=2025.12.19", + "hatchling>=1.28", + "sphinx>=9.0.4", + "sphinx-argparse-cli>=1.23", + "sphinx-autodoc-typehints>=3.17", + "sphinxcontrib-mermaid>=2.1.1", +] +build = [ + "hatch-fancy-pypi-readme>=25.1", + "hatch-vcs>=0.5", + "hatchling>=1.32.4", +] +lint = [ "pre-commit-uv>=4.3" ] +pkg-meta = [ "check-wheel-contents>=0.6.3", "twine>=7", "uv>=0.12.22" ] +release-news = [ + "release-creator>=4.4", + "towncrier>=26.9", +] +release-notes = [ + "release-creator>=4.4", +] +release-prepare = [ + "prek>=0.5", + "release-creator>=4.4", + "towncrier>=26.9", +] + +[tool.hatch] +version.source = "vcs" +metadata.hooks.fancy-pypi-readme.content-type = "text/markdown" +metadata.hooks.fancy-pypi-readme.fragments = [ { path = "README.md" } ] +build.hooks.vcs.version-file = "src/python_sbom/version.py" +# writes python-sbom's own SBOM with the hook it ships, after the vcs hook writes the version +build.hooks.custom.path = "hatch_build.py" + +[tool.uv] +dependency-groups.release-news = { requires-python = ">=3.15" } +dependency-groups.release-notes = { requires-python = ">=3.15" } +dependency-groups.release-prepare = { requires-python = ">=3.15" } + +[tool.ruff] +target-version = "py311" +line-length = 120 +format.preview = true +format.docstring-code-line-length = 100 +format.docstring-code-format = true +lint.select = [ + "ALL", +] +lint.ignore = [ + "CPY", # The project does not add copyright headers to individual files + "DOC", # Public API documentation is generated from signatures + "incorrect-blank-line-before-class", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "missing-trailing-comma", # Conflict with formatter + "multi-line-summary-first-line", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible + "single-line-implicit-string-concatenation", # Conflict with formatter + "undocumented-magic-method", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-class", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-function", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-init", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-method", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-nested-class", # undocumented-public-api (simplint SMP903) covers the public API instead +] +lint.per-file-ignores."tests/**/*.py" = [ + "assert", # Tests use assertions + "D", # Test names document behavior + "FBT", # Boolean parameters keep parameterized cases compact + "implicit-namespace-package", # The tests directory is not a package + "import-private-name", # Unit tests cover the private modules behind the small public API + "magic-value-comparison", # Literal expected values keep assertions local + "subprocess-without-shell-equals-true", # Entry-point arguments are fixed by the tests + "suspicious-subprocess-import", # Entry-point tests invoke a local Python executable +] +# the fixture mirrors virtualenv's seed/wheels/embed/__init__.py, which holds the bundle table +lint.per-file-ignores."tests/_seeder/src/**/__init__.py" = [ "non-empty-init-module" ] +lint.isort = { known-first-party = [ + "python_sbom", +], required-imports = [ + "from __future__ import annotations", +] } +lint.preview = true + +[tool.codespell] +builtin = "clear,usage,en-GB_to_en-US" +ignore-words-list = "acknowledgement" +count = true + +[tool.pyproject-fmt] +max_supported_python = "3.15" + +[tool.ty] +environment.python-version = "3.15" + +[tool.pytest] +ini_options.testpaths = [ "tests" ] +# test modules mirror the source modules, so their names repeat across directories +ini_options.addopts = [ "--import-mode=importlib" ] +ini_options.filterwarnings = [ + "error", +] +ini_options.verbosity_assertions = 2 + +[tool.coverage] +run.parallel = true +run.plugins = [ + "covdefaults", +] +run.core = "ctrace" # sysmon (3.14+ default) cannot record the per-test contexts we collect +paths.source = [ + "src", + ".tox*/*/lib/python*/site-packages", + ".tox*/pypy*/site-packages", + ".tox*\\*\\Lib\\site-packages", + "*/src", + "*\\src", +] +report.fail_under = 100 +html.show_contexts = true +html.skip_covered = false + +[tool.towncrier] +name = "python-sbom" +directory = "docs/changelog.d" +filename = "docs/changelog.rst" +template = "release_creator.fragments:template.rst.jinja2" +title_format = false +underlines = [ "-", "~", "^" ] +type = [ + { directory = "feature", name = "Added", showcontent = true }, + { directory = "change", name = "Changes", showcontent = true }, + { directory = "bugfix", name = "Fixed", showcontent = true }, + { directory = "doc", name = "Documentation", showcontent = true }, + { directory = "breaking", name = "Breaking changes", showcontent = true }, + { directory = "security", name = "Security", showcontent = true }, + { directory = "performance", name = "Performance", showcontent = true }, + { directory = "deprecation", name = "Deprecated", showcontent = true }, + { directory = "removal", name = "Removed", showcontent = true }, + { directory = "packaging", name = "Packaging", showcontent = true }, +] + +[tool.python-sbom] +supplier = { name = "tox-dev", url = [ "https://github.com/tox-dev" ] } + +[tool.simplint] +docstring-style = "rst" +# the fixture mirrors virtualenv's seed/wheels/embed/__init__.py, which holds the bundle table +per-file-ignores."tests/_seeder/src/**/__init__.py" = [ + "init-module-content", + "missing-final", + "module-variable-prefix" +] + +[tool.nab] +mode = "universal" +base-group = "project-runtime" +uploaded-prior-to = "P7D" +decision-order = "stable" +matrix.python = ">=3.15,<3.16" +matrix.platforms = [ "linux_x86_64" ] +# release-creator 4.4.0 needs a seven-day cutoff exception. +# expires 2026-11-09 +packages.release-creator.uploaded-prior-to = 2026-11-02T21:45:00Z +# simplint 0.7.1, the pre-commit hook, needs a seven-day cutoff exception. +# expires 2026-11-13 +packages.simplint.uploaded-prior-to = 2026-11-06T21:30:00Z diff --git a/src/python_sbom/__init__.py b/src/python_sbom/__init__.py new file mode 100644 index 0000000..25363ff --- /dev/null +++ b/src/python_sbom/__init__.py @@ -0,0 +1,46 @@ +"""SBOMs for Python distributions: write PEP 770 CycloneDX SBOMs into wheels.""" + +from __future__ import annotations + +from ._errors import SbomError +from ._model._content import ContentProvider, ProviderContext +from ._model._identity import ProjectIdentity +from ._model._inputs import BundledWheelInfo, Contributions, Settings +from ._model._types import ( + Component, + Contact, + Dependency, + Evidence, + ExternalReference, + Hash, + Identity, + IdentityMethod, + License, + LicenseInfo, + OrganizationalEntity, + Property, +) +from .version import __version__ + +__all__ = [ + "BundledWheelInfo", + "Component", + "Contact", + "ContentProvider", + "Contributions", + "Dependency", + "Evidence", + "ExternalReference", + "Hash", + "Identity", + "IdentityMethod", + "License", + "LicenseInfo", + "OrganizationalEntity", + "ProjectIdentity", + "Property", + "ProviderContext", + "SbomError", + "Settings", + "__version__", +] diff --git a/src/python_sbom/__main__.py b/src/python_sbom/__main__.py new file mode 100644 index 0000000..a528915 --- /dev/null +++ b/src/python_sbom/__main__.py @@ -0,0 +1,10 @@ +"""Main entry point.""" + +from __future__ import annotations + +import sys + +from ._cli._main import main + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/src/python_sbom/_cli/__init__.py b/src/python_sbom/_cli/__init__.py new file mode 100644 index 0000000..f60ec27 --- /dev/null +++ b/src/python_sbom/_cli/__init__.py @@ -0,0 +1 @@ +"""The command line: parsing options and running one command per subcommand.""" diff --git a/src/python_sbom/_cli/_commands.py b/src/python_sbom/_cli/_commands.py new file mode 100644 index 0000000..2b4805d --- /dev/null +++ b/src/python_sbom/_cli/_commands.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +import sys +import zipfile +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +from python_sbom._collect._content import load_content_provider +from python_sbom._collect._facts import collect_facts +from python_sbom._errors import SbomError +from python_sbom._formats._json import dumps +from python_sbom._formats._spdx import to_spdx +from python_sbom._generate._archive import describe_archive +from python_sbom._generate._embed import embed_sbom, revision_reader +from python_sbom._validate._artifact import validate as validate_artifact +from python_sbom._validate._document import MAX_COMPONENTS, is_document + +if TYPE_CHECKING: + from argparse import ArgumentParser, _SubParsersAction + + from ._options import Options + + +def add_embed(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser( + "embed", + help="write the SBOM into wheels another build backend built", + description="Write the SBOM the hatchling hook writes into wheels another build backend built, such as" + " meson-python. Options come from the [tool.python-sbom] table of the project's pyproject.toml.", + ) + parser.set_defaults(handler=_embed) + parser.add_argument("wheels", type=Path, nargs="+", metavar="WHEEL", help="wheel to rewrite in place") + _add_build_options(parser) + + +def _embed(options: Options) -> bool: + root = Path.cwd() if options.root is None else options.root + content = None if options.content_provider is None else load_content_provider(options.content_provider, root) + for wheel in options.wheels: + written = embed_sbom(wheel, root, content=content, commit=options.commit, build_env=options.build_env) + sys.stdout.write(f"wrote {written} into {wheel.name}\n") + return True + + +def _add_build_options(parser: ArgumentParser) -> None: + parser.add_argument( + "--root", type=Path, metavar="DIR", help="project directory of the artifact (default: current directory)" + ) + parser.add_argument( + "--commit", metavar="SHA", help="source revision to record, for an artifact built from an sdist" + ) + parser.add_argument( + "--build-env", + action="append", + metavar="DIR", + help="site-packages directory of the environment that built the artifact; repeat it per directory" + " (default: this interpreter's)", + ) + parser.add_argument( + "--content-provider", + metavar="MODULE:ATTRIBUTE", + help="content provider, overriding the content-provider option", + ) + + +def add_archive(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser( + "archive", + help="write the SBOM of a zip archive that is not a wheel, such as a zipapp", + description="Describe a zip archive that holds the project's .dist-info at its top level, such as a zipapp:" + " the archive's own files, the project's distribution, and every wheel it bundles, as a file or unpacked," + " described as the wheel's SBOM describes them. Options come from [tool.python-sbom].", + ) + parser.set_defaults(handler=_archive) + parser.add_argument("archive", type=Path, metavar="ARCHIVE", help="archive to describe") + _add_build_options(parser) + parser.add_argument( + "--build-script", + dest="build_scripts", + action="append", + metavar="PATH", + help="script, relative to the project, that built the archive; repeat it per script", + ) + parser.add_argument( + "--output", type=Path, metavar="FILE", help="SBOM file to write (default: .cdx.json next to it)" + ) + parser.add_argument( + "--embed", action="store_true", help="also add the SBOM to the archive, at its top level as .cdx.json" + ) + + +def _archive(options: Options) -> bool: + root = Path.cwd() if options.root is None else options.root + facts = collect_facts(root, revision_reader(options.commit), path=options.build_env) + document = describe_archive( + root, + options.archive, + facts=facts, + content=None if options.content_provider is None else load_content_provider(options.content_provider, root), + build_scripts=tuple(PurePosixPath(script) for script in options.build_scripts or []), + ) + name = f"{options.archive.name}.cdx.json" + (target := options.output or options.archive.with_name(name)).write_bytes(content := dumps(document)) + sys.stdout.write(f"wrote {target}\n") + if options.embed: + with zipfile.ZipFile(options.archive, "a") as handle: + if name in handle.namelist(): + msg = f"{options.archive.name} already holds {name}" + raise SbomError(msg) + # a fixed time and Unix attributes, so the archive's bytes are the same whichever system adds the entry + entry = zipfile.ZipInfo(name, datetime.fromtimestamp(max(facts.epoch, 315532800), tz=UTC).timetuple()[:6]) + entry.create_system, entry.external_attr = 3, 0o644 << 16 + handle.writestr(entry, content) + sys.stdout.write(f"wrote {name} into {options.archive.name}\n") + return True + + +def add_spdx(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser("spdx", help="render a CycloneDX SBOM as SPDX 2.3 JSON") + parser.set_defaults(handler=_spdx) + parser.add_argument("source", type=Path, metavar="SOURCE", help="CycloneDX JSON file to read") + parser.add_argument("target", type=Path, metavar="TARGET", help="SPDX JSON file to write") + + +def _spdx(options: Options) -> bool: + if not is_document(loaded := json.loads(options.source.read_text(encoding="utf-8"))): + msg = f"{options.source} is not a CycloneDX document python-sbom can render" + raise SbomError(msg) + try: + rendered = to_spdx(loaded) + except (KeyError, TypeError) as error: + # the CLI renders without the schema check, which needs the validate extra + msg = f"{options.source} is not a CycloneDX document python-sbom can render: {error!r}" + raise SbomError(msg) from error + # bytes, so the line endings are the same on every operating system + options.target.write_bytes(rendered.encode()) + return True + + +def add_validate(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser( + "validate", + help="validate the SBOMs in wheels and archives", + description="Check every CycloneDX SBOM a wheel or archive holds against the CycloneDX 1.6 schema; for one" + " python-sbom wrote, also its serial number, references, SPDX rendering and, with --allow-license, the licenses" + " of what it ships. Needs the validate extra: python-sbom[validate].", + ) + parser.set_defaults(handler=_validate) + parser.add_argument( + "paths", type=Path, nargs="+", metavar="PATH", help="wheel, archive, or directory holding wheels" + ) + parser.add_argument( + "--allow-license", + dest="allowed_licenses", + action="append", + metavar="SPDX-ID", + help="SPDX license id shipped components may use; repeat it per id, leave it out to skip the license policy", + ) + parser.add_argument( + "--max-components", + type=int, + default=MAX_COMPONENTS, + metavar="N", + help="reject an SBOM listing more than N components, nested ones included, without checking it further, since" + f" the schema check slows with the square of the count (default: {MAX_COMPONENTS})", + ) + + +def _validate(options: Options) -> bool: + allowed = None if options.allowed_licenses is None else frozenset(options.allowed_licenses) + if not ( + artifacts := sorted( + artifact for path in options.paths for artifact in (path.glob("*.whl") if path.is_dir() else [path]) + ) + ): + sys.stderr.write(f"no wheel found in {', '.join(map(str, options.paths))}\n") + return False + valid = True + for artifact in artifacts: + for problem in (problems := validate_artifact(artifact, allowed, options.max_components)): + sys.stderr.write(f"SBOM invalid: {artifact.name}: {problem}\n") + if problems: + valid = False + else: + sys.stdout.write(f"SBOM in {artifact.name} is valid\n") + return valid + + +__all__ = [ + "add_archive", + "add_embed", + "add_spdx", + "add_validate", +] diff --git a/src/python_sbom/_cli/_main.py b/src/python_sbom/_cli/_main.py new file mode 100644 index 0000000..2c7d71b --- /dev/null +++ b/src/python_sbom/_cli/_main.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +from python_sbom._errors import SbomError + +from ._options import Options +from ._parser import build_parser + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def main(args: Sequence[str] | None = None) -> None: + options = build_parser().parse_args(args, namespace=Options()) + try: + succeeded = options.handler(options) + except SbomError as error: + sys.stderr.write(f"python-sbom: {error}\n") + succeeded = False + if not succeeded: + raise SystemExit(1) + + +__all__ = [ + "main", +] diff --git a/src/python_sbom/_cli/_options.py b/src/python_sbom/_cli/_options.py new file mode 100644 index 0000000..81d0afc --- /dev/null +++ b/src/python_sbom/_cli/_options.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from argparse import Namespace +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +class Options(Namespace): + # each subcommand sets only its own fields; its handler reads only those + handler: Callable[[Options], bool] + wheels: list[Path] + archive: Path + root: Path | None + commit: str | None + build_env: list[str] | None + build_scripts: list[str] | None + content_provider: str | None + output: Path | None + embed: bool + source: Path + target: Path + paths: list[Path] + allowed_licenses: list[str] | None + max_components: int + + +__all__ = [ + "Options", +] diff --git a/src/python_sbom/_cli/_parser.py b/src/python_sbom/_cli/_parser.py new file mode 100644 index 0000000..2f1576f --- /dev/null +++ b/src/python_sbom/_cli/_parser.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from argparse import ArgumentParser + +from python_sbom.version import __version__ + +from ._commands import add_archive, add_embed, add_spdx, add_validate + + +def build_parser() -> ArgumentParser: + parser = ArgumentParser(prog="python-sbom", description="Write, convert and check the CycloneDX SBOMs of wheels.") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + commands = parser.add_subparsers(required=True) + for add in (add_embed, add_archive, add_spdx, add_validate): + add(commands) + return parser + + +__all__ = [ + "build_parser", +] diff --git a/src/python_sbom/_collect/__init__.py b/src/python_sbom/_collect/__init__.py new file mode 100644 index 0000000..8083326 --- /dev/null +++ b/src/python_sbom/_collect/__init__.py @@ -0,0 +1 @@ +"""Facts gathered at the edge: settings, the build environment, the source revision and the project tree.""" diff --git a/src/python_sbom/_collect/_content.py b/src/python_sbom/_collect/_content.py new file mode 100644 index 0000000..a34fac2 --- /dev/null +++ b/src/python_sbom/_collect/_content.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import inspect +import pkgutil +import sys +from pathlib import Path, PurePosixPath + +from python_sbom._errors import SettingsError +from python_sbom._model._content import ContentProvider + + +def load_content_provider(spec: str | None, root: Path) -> ContentProvider: + if spec is None: + return ContentProvider() + # resolve_name also takes a bare module, which is no provider + module_name, _, attribute = spec.partition(":") + if not (module_name and attribute): + msg = f"content provider {spec!r} must be `module:attribute`" + raise SettingsError(msg) + sys.path.insert(0, str(root)) + try: + provider = pkgutil.resolve_name(spec) + # ValueError: a name resolve_name cannot parse, such as a second colon + except (ImportError, AttributeError, ValueError) as error: + msg = f"content provider {spec!r} does not load from {root}: {error}" + raise SettingsError(msg) from error + finally: + sys.path.remove(str(root)) + if isinstance(provider, type) and issubclass(provider, ContentProvider): + provider = provider() + if not isinstance(provider, ContentProvider): + msg = f"content provider {spec!r} is not a ContentProvider subclass or instance" + raise SettingsError(msg) + return provider + + +def provider_script(provider: ContentProvider, root: Path) -> PurePosixPath | None: + # a provider defined in the project shapes the document, so the SBOM lists its module among the build tools + # a method's code names the file even for a module hatchling loaded outside the import system, such as a custom + # hook script + cls = type(provider) + methods = ( + function.__code__.co_filename + for _, function in inspect.getmembers(cls, inspect.isfunction) + if function.__qualname__.startswith(f"{cls.__qualname__}.") + ) + if (source := next(methods, getattr(sys.modules.get(cls.__module__), "__file__", None))) is None: + return None + resolved, base = Path(source).resolve(), root.resolve() + return PurePosixPath(resolved.relative_to(base).as_posix()) if resolved.is_relative_to(base) else None + + +__all__ = [ + "load_content_provider", + "provider_script", +] diff --git a/src/python_sbom/_collect/_facts.py b/src/python_sbom/_collect/_facts.py new file mode 100644 index 0000000..4d9b316 --- /dev/null +++ b/src/python_sbom/_collect/_facts.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +import os +import platform +import re +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] # runs git to read the source revision +import sys +import time +import warnings +from importlib.metadata import distributions +from itertools import starmap +from typing import TYPE_CHECKING, Final, Protocol + +from installer.records import InvalidRecordEntry, RecordEntry, parse_record_file +from installer.utils import parse_metadata_file +from packaging.direct_url import DirectUrl, DirectUrlValidationError +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name + +from python_sbom._errors import SettingsError +from python_sbom._model._inputs import BuildFacts, InstalledDistribution, Interpreter, RecordRow, Revision +from python_sbom._model._metadata import parse_metadata +from python_sbom._wheel._record import decode_digest + +if TYPE_CHECKING: + from collections.abc import Container, Sequence + from importlib.metadata import Distribution + from pathlib import Path + + from python_sbom._model._metadata import CoreMetadata + +#: the file an sdist carries its source revision in, as ``node: ``, for the wheel built from it +REVISION_FILE: Final[str] = ".python-sbom-revision" +_COMMIT: Final[re.Pattern[str]] = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}") + + +class RevisionReader(Protocol): + def __call__(self, root: Path, /) -> Revision | None: ... + + +def collect_facts( + root: Path, revision: RevisionReader, *, path: Sequence[str] | None = None, default_epoch: int | None = None +) -> BuildFacts: + if from_environment := bool(raw := os.environ.get("SOURCE_DATE_EPOCH", "")): + try: + epoch = int(raw) + except ValueError as error: + msg = f"SOURCE_DATE_EPOCH must be a Unix timestamp, got {raw!r}" + raise SettingsError(msg) from error + else: + epoch = int(time.time()) if default_epoch is None else default_epoch + return BuildFacts( + epoch=epoch, + epoch_from_environment=from_environment, + revision=revision(root), + interpreter=Interpreter( + name=sys.implementation.name, + version=platform.python_version(), + implementation=platform.python_implementation(), + ), + distributions=_installed(path), + ) + + +def _installed(path: Sequence[str] | None) -> list[InstalledDistribution]: + found: Final[dict[str, tuple[Distribution, CoreMetadata]]] = {} + for distribution in distributions(path=list(path)) if path is not None else distributions(): + # where Distribution.metadata looks, a .dist-info's METADATA, then an .egg-info's PKG-INFO or the file itself + text = distribution.read_text("METADATA") or distribution.read_text("PKG-INFO") or distribution.read_text("") + # the first on the path is the one imports load, so a shadowed copy further down is left out + if (metadata := parse_metadata(text or "")).name and metadata.version: + found.setdefault(canonicalize_name(metadata.name), (distribution, metadata)) + return [ + InstalledDistribution( + name=name, + metadata=metadata, + files=_recorded_files(distribution), + depends_on=_depends_on(name, distribution, metadata, found), + ) + for name, (distribution, metadata) in found.items() + ] + + +def _recorded_files(distribution: Distribution) -> list[RecordRow]: + # a platform wheel's files are built for this machine's OS and architecture, and an editable install's point at a + # checkout, so listing either would tie the document to one machine + if parse_metadata_file(distribution.read_text("WHEEL") or "")["Root-Is-Purelib"] != "true" or _editable( + distribution + ): + return [] + # RECORD read once and as written: Distribution.files would stat every row to drop files missing on disk, and + # build a path object for each, while the SBOM lists what the install recorded + try: + entries: Final[list[RecordEntry]] = list( + starmap(RecordEntry.from_elements, parse_record_file((distribution.read_text("RECORD") or "").splitlines())) + ) + except InvalidRecordEntry as error: + warnings.warn(f"listed no files of {distribution.name}, its RECORD is malformed: {error}", stacklevel=2) + return [] + rows: Final[list[RecordRow]] = [] + for entry in entries: + if entry.hash_ is None or not _describes_distribution(entry.path): + continue + algorithm, digest = decode_digest(str(entry.hash_), entry.path) + rows.append(RecordRow(path=entry.path, algorithm=algorithm, digest=digest, size=str(entry.size))) + return rows + + +def _editable(distribution: Distribution) -> bool: + if (text := distribution.read_text("direct_url.json")) is None: + return False + try: + dir_info = DirectUrl.from_dict(json.loads(text)).dir_info + # an installer that wrote the file wrong breaks no build: its files get listed, as for any other install + except (ValueError, DirectUrlValidationError) as error: + warnings.warn(f"read the direct_url.json of {distribution.name} as not editable: {error}", stacklevel=2) + return False + return dir_info is not None and dir_info.editable is True + + +def _describes_distribution(path: str) -> bool: + # console-script launchers live outside site-packages and embed the environment's interpreter path; the installer + # metadata names the frontend; uv's cache files hold paths and times; none of them says anything about the package + directory, _, name = path.rpartition("/") + return not ( + path.startswith("../") + or (directory.endswith(".dist-info") and name in {"INSTALLER", "REQUESTED", "direct_url.json"}) + or re.fullmatch(r"uv_.*\.json", name) + ) + + +def _depends_on(name: str, distribution: Distribution, metadata: CoreMetadata, installed: Container[str]) -> list[str]: + names: Final[set[str]] = set() + # an .egg-info states its requirements in requires.txt, which Distribution.requires reads + for line in metadata.values("requires_dist") or distribution.requires or []: + try: + requirement = Requirement(line) + except InvalidRequirement as error: + # old distributions declare requirements, such as "pytz (>dev)", that today's syntax rejects + warnings.warn(f"left out the requirement {line!r} of {name}: {error}", stacklevel=2) + continue + if (required := canonicalize_name(requirement.name)) in installed and ( + requirement.marker is None or requirement.marker.evaluate() + ): + names.add(required) + return sorted(names) + + +def default_revision(root: Path, /) -> Revision | None: + # the variable exists for builds from an sdist, so it wins over whatever checkout the sdist was unpacked into + if commit := os.environ.get("PYTHON_SBOM_COMMIT"): + return commit_revision(commit, "PYTHON_SBOM_COMMIT") + if (found := git_revision(root)) is not None: + return found + # the sdist build wrote it from the revision it resolved, so it is at least as exact as an archival file + if (recorded := root / REVISION_FILE).is_file(): + return commit_revision(recorded.read_text(encoding="utf-8").strip().removeprefix("node: "), REVISION_FILE) + archival = root / ".git_archival.txt" + if archival.is_file() and ( + match := re.search(rf"^node: ({_COMMIT.pattern})$", archival.read_text(encoding="utf-8"), re.MULTILINE) + ): + return Revision(commit=match[1], dirty=False) + return None + + +def commit_revision(commit: str, origin: str) -> Revision: + # the commit lands in the source link, so anything but a full hash could point the link elsewhere + if not _COMMIT.fullmatch(commit): + msg = f"{origin} must be a full lowercase hex commit hash of 40 or 64 characters, got {commit!r}" + raise SettingsError(msg) + return Revision(commit=commit, dirty=False) + + +def git_revision(root: Path, /) -> Revision | None: + resolved = root.resolve() + if (git := shutil.which("git")) is None or not any( + (directory / ".git").exists() for directory in (resolved, *resolved.parents) + ): + return None + try: + # a project unpacked into another checkout, such as an sdist under its build directory, is not its source + _git(git, root, "ls-files", "--error-unmatch", "--", "pyproject.toml") + commit = _git(git, root, "rev-parse", "--verify", "--quiet", "HEAD") + # untracked files count, since the build packages every file .gitignore lets through + changes = _git(git, root, "status", "--porcelain", "--", ".") + except subprocess.CalledProcessError as error: + if error.returncode != 1: # 1: the project is untracked, or the checkout has no commit yet + warnings.warn(f"git cannot read the source revision in {root}: {error.stderr.strip()}", stacklevel=2) + return None + return Revision(commit=commit, dirty=bool(changes)) + + +def _git(git: str, root: Path, *arguments: str) -> str: + # git first runs in whichever repository encloses the project, whose fsmonitor hook would run a command from its + # config; no optional locks, so status never rewrites the index under another git process + return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # git from PATH with fixed arguments + [git, "-c", "core.fsmonitor=false", "--no-optional-locks", "-C", str(root), *arguments], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +__all__ = [ + "REVISION_FILE", + "RevisionReader", + "collect_facts", + "commit_revision", + "default_revision", + "git_revision", +] diff --git a/src/python_sbom/_collect/_settings.py b/src/python_sbom/_collect/_settings.py new file mode 100644 index 0000000..b90ac18 --- /dev/null +++ b/src/python_sbom/_collect/_settings.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import tomllib +from typing import TYPE_CHECKING, TypeAlias + +from python_sbom._errors import SettingsError +from python_sbom._model._inputs import Settings + +if TYPE_CHECKING: + from collections.abc import Mapping + from datetime import date, datetime, time + from pathlib import Path + from typing import TypeIs # new in 3.13; annotations here are never evaluated + + from python_sbom._model._types import OrganizationalEntity + +#: a value of a TOML table, as :mod:`tomllib` or hatchling hand it over +Toml: TypeAlias = "str | int | float | bool | datetime | date | time | list[Toml] | dict[str, Toml]" + + +def settings_from_pyproject(root: Path, overrides: Mapping[str, Toml]) -> Settings: + pyproject = root / "pyproject.toml" + try: + loaded = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except FileNotFoundError as error: + msg = f"{root} has no pyproject.toml to read [tool.python-sbom] from" + raise SettingsError(msg) from error + except tomllib.TOMLDecodeError as error: + msg = f"{pyproject} is not valid TOML: {error}" + raise SettingsError(msg) from error + table = loaded.get("tool", {}).get("python-sbom", {}) + return settings_from_table({**table, **overrides}, f"[tool.python-sbom] of {pyproject}") + + +def settings_from_table(table: Mapping[str, Toml], where: str) -> Settings: + known = {"bundled-wheels", "content-provider", "default-branch", "repository", "supplier"} + if unknown := sorted(table.keys() - known): + msg = f"{where} has unknown option(s) {', '.join(unknown)}; known: {', '.join(sorted(known))}" + raise SettingsError(msg) + if not isinstance(repository := table.get("repository"), str | None): + msg = f"`repository` in {where} must be a string" + raise SettingsError(msg) + if not isinstance(default_branch := table.get("default-branch", "main"), str): + msg = f"`default-branch` in {where} must be a string" + raise SettingsError(msg) + if not _strings(patterns := table.get("bundled-wheels", [])): + msg = f"`bundled-wheels` in {where} must be an array of strings" + raise SettingsError(msg) + if not isinstance(content_provider := table.get("content-provider"), str | None): + msg = f"`content-provider` in {where} must be a string, `module:attribute`" + raise SettingsError(msg) + return Settings( + supplier=_supplier(table.get("supplier"), where), + repository=None if repository is None else repository.rstrip("/"), + default_branch=default_branch, + bundled_wheels=tuple(patterns), + content_provider=content_provider, + ) + + +def _strings(values: object) -> TypeIs[list[str]]: + return isinstance(values, list) and all(isinstance(value, str) for value in values) + + +def _supplier(value: Toml | None, where: str) -> OrganizationalEntity | None: + if value is None: + return None + if ( + isinstance(value, dict) + and value.keys() <= {"name", "url"} + and isinstance(name := value.get("name"), str) + and _strings(urls := value.get("url", [])) + ): + return {"name": name, "url": urls} + msg = f"`supplier` in {where} must be a table with a string `name` and an optional `url` string array" + raise SettingsError(msg) + + +__all__ = [ + "Toml", + "settings_from_pyproject", + "settings_from_table", +] diff --git a/src/python_sbom/_collect/_source.py b/src/python_sbom/_collect/_source.py new file mode 100644 index 0000000..d4a49e4 --- /dev/null +++ b/src/python_sbom/_collect/_source.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import hashlib +from collections import Counter +from io import BytesIO +from itertools import starmap +from pathlib import PurePath +from typing import TYPE_CHECKING, Final + +from installer.records import InvalidRecordEntry, RecordEntry, parse_record_file +from installer.sources import WheelFile +from packaging.requirements import InvalidRequirement, Requirement + +from python_sbom._errors import SettingsError, WheelError +from python_sbom._model._inputs import BuildScript, BundledWheel, RecordRow, SbomRequest, SourceTree +from python_sbom._model._metadata import CoreMetadata, parse_metadata +from python_sbom._wheel._archive import open_archive, read_member, read_wheel_metadata +from python_sbom._wheel._record import decode_digest + +if TYPE_CHECKING: + import zipfile + from pathlib import Path, PurePosixPath + + from python_sbom._model._inputs import Settings + + +def read_source_tree(request: SbomRequest) -> SourceTree: + root = request.root + for pattern in request.settings.bundled_wheels: + # a wheel outside the project is none the project ships, and pathlib refuses an absolute pattern outright + if (path := PurePath(pattern)).anchor or ".." in path.parts: + msg = f"`bundled-wheels` pattern {pattern!r} must be relative to the project, without `..`" + raise SettingsError(msg) + return SourceTree( + copyright=next( + ( + line + for license_file in request.metadata.values("license_files") + if (path := root / license_file).is_file() + for line in path.read_text(encoding="utf-8").splitlines() + if line.startswith("Copyright") + ), + None, + ), + bundled=[ + read_bundled_wheel((root / relative).read_bytes(), relative) + for relative in sorted({ + wheel.relative_to(root).as_posix() + for pattern in request.settings.bundled_wheels + for wheel in root.glob(pattern) + }) + ], + # str, so a Windows drive in an absolute path survives the join with the native root + build_scripts=[ + BuildScript(path=script, sha256=hashlib.sha256((root / str(script)).read_bytes()).hexdigest()) + for script in request.build_scripts + ], + ) + + +def read_bundled_wheel(data: bytes, path: str) -> BundledWheel: + where: Final[str] = f"bundled wheel {path}" + with open_archive(BytesIO(data), where) as archive: + # installer takes the project name from the archive's file name, which an archive read from memory lacks + archive.filename = path + members: Final[list[str]] = sorted(name for name in archive.namelist() if not name.endswith("/")) + wheel, metadata = read_wheel_metadata(archive, where) + dist_info: Final[str] = wheel.dist_info_dir + _check_named(metadata, where, f"{dist_info}/METADATA") + files: Final[list[RecordRow]] = _verified_record(archive, wheel, where) + vendored: Final[list[tuple[str, CoreMetadata]]] = [ + *_vendor_manifest(archive, where, members), + *( + (name, _vendored_metadata(archive, where, name)) + for name in members + if "/_vendor/" in name and name.endswith(".dist-info/METADATA") + ), + ] + return BundledWheel( + path=path, + sha256=hashlib.sha256(data).hexdigest(), + metadata_name=f"{dist_info}/METADATA", + metadata=metadata, + files=files, + vendored=vendored, + ) + + +def _check_named(metadata: CoreMetadata, where: str, name: str) -> None: + if not (metadata.name and metadata.version): + msg = f"{where}: {name} lacks Name or Version" + raise WheelError(msg) + + +def _verified_record(archive: zipfile.ZipFile, wheel: WheelFile, where: str) -> list[RecordRow]: + # the SBOM copies these rows, so a RECORD that misstates the wheel would make the SBOM misstate it too + dist_info: Final[str] = wheel.dist_info_dir + # installer decompresses RECORD whole, so a capped read comes first and bounds installer's read of the same bytes + record: Final[str] = read_member(archive, f"{dist_info}/RECORD", where).decode("utf-8") + try: + rows: Final[list[tuple[str, str, str]]] = list(parse_record_file(record.splitlines())) + except InvalidRecordEntry as error: + msg = f"{where}: RECORD does not parse: {error}" + raise WheelError(msg) from error + # installer checks the last row of a path only, skips rows naming no member, and lets a RECORD.jws or RECORD.p7s + # anywhere under the .dist-info, "../" included, go unlisted, so each of these checks is ours + if repeated := sorted(path for path, count in Counter(path for path, _, _ in rows).items() if count > 1): + msg = f"{where}: RECORD lists {', '.join(repeated)} more than once" + raise WheelError(msg) + members: Final[set[str]] = {info.filename for info in archive.infolist() if not info.is_dir()} + recorded: Final[set[str]] = {path for path, _, _ in rows} + # installer checks the row of RECORD itself, the one row without a digest + if missing := sorted( + members - recorded - {f"{dist_info}/{name}" for name in ("RECORD", "RECORD.jws", "RECORD.p7s")} + ): + msg = f"{where}: RECORD lacks a digest for {', '.join(missing)}" + raise WheelError(msg) + if extra := sorted(recorded - members): + msg = f"{where}: RECORD lists {', '.join(extra)}, which the wheel does not hold" + raise WheelError(msg) + try: + wheel.validate_record() + except WheelFile.validation_error as error: + # installer documents the exception class only, not the list of issues it carries + issues: Final[list[str]] = getattr(error, "issues", [str(error)]) + prefix: Final[str] = f"In {archive.filename}, " + msg = f"{where}: RECORD disagrees with the wheel: {'; '.join(issue.removeprefix(prefix) for issue in issues)}" + raise WheelError(msg) from error + found: Final[list[RecordRow]] = [] + for entry in starmap(RecordEntry.from_elements, rows): + # the row of RECORD itself + if entry.hash_ is None: + continue + algorithm, digest = decode_digest(str(entry.hash_), f"{where}: RECORD row for {entry.path}") + found.append(RecordRow(path=entry.path, algorithm=algorithm, digest=digest, size=str(entry.size))) + return found + + +def _vendor_manifest(archive: zipfile.ZipFile, where: str, members: list[str]) -> list[tuple[str, CoreMetadata]]: + # pip lists what it vendors in _vendor/vendor.txt instead of shipping each vendored .dist-info + found: Final[list[tuple[str, CoreMetadata]]] = [] + for name in (member for member in members if member.endswith("/_vendor/vendor.txt")): + for line in read_member(archive, name, where).decode("utf-8").splitlines(): + if not (text := line.partition("#")[0].strip()): + continue + try: + pins = list((requirement := Requirement(text)).specifier) + except InvalidRequirement as error: + msg = f"{where}: vendored requirement in {name} does not parse: {text}" + raise WheelError(msg) from error + if len(pins) != 1 or pins[0].operator != "==" or "*" in pins[0].version: + msg = f"{where}: vendored requirement in {name} pins no one exact version: {text}" + raise WheelError(msg) + found.append((name, CoreMetadata(raw={"name": requirement.name, "version": pins[0].version}, unparsed={}))) + return found + + +def _vendored_metadata(archive: zipfile.ZipFile, where: str, name: str) -> CoreMetadata: + metadata: Final[CoreMetadata] = parse_metadata(read_member(archive, name, where).decode("utf-8")) + _check_named(metadata, where, name) + return metadata + + +def wheel_request(wheel: Path, root: Path, settings: Settings, build_scripts: tuple[PurePosixPath, ...]) -> SbomRequest: + with open_archive(wheel, str(wheel)) as archive: + _, metadata = read_wheel_metadata(archive, str(wheel)) + return SbomRequest( + root=root, metadata=metadata, wheel_name=wheel.name, settings=settings, build_scripts=build_scripts + ) + + +__all__ = [ + "read_bundled_wheel", + "read_source_tree", + "wheel_request", +] diff --git a/src/python_sbom/_errors.py b/src/python_sbom/_errors.py new file mode 100644 index 0000000..154f689 --- /dev/null +++ b/src/python_sbom/_errors.py @@ -0,0 +1,24 @@ +from __future__ import annotations + + +class SbomError(Exception): + """Base of the errors this package raises; the command line reports them without a traceback.""" + + +# callers catch these as SbomError, or as the built-in some of them also are; SettingsError covers every option, +# file or variable the project sets wrongly +class SettingsError(SbomError): ... # simplint: ignore[SMP704] # see above + + +class WheelError(SbomError, ValueError): ... # simplint: ignore[SMP704] # see above + + +class MissingExtraError(SbomError, ImportError): ... # simplint: ignore[SMP704] # see above + + +__all__ = [ + "MissingExtraError", + "SbomError", + "SettingsError", + "WheelError", +] diff --git a/src/python_sbom/_formats/__init__.py b/src/python_sbom/_formats/__init__.py new file mode 100644 index 0000000..539b271 --- /dev/null +++ b/src/python_sbom/_formats/__init__.py @@ -0,0 +1 @@ +"""Serialization of documents: CycloneDX JSON and the SPDX 2.3 rendering.""" diff --git a/src/python_sbom/_formats/_json.py b/src/python_sbom/_formats/_json.py new file mode 100644 index 0000000..4ec244d --- /dev/null +++ b/src/python_sbom/_formats/_json.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from python_sbom._model._types import Document + + from ._spdx import Spdx + + +def dumps(document: Document | Spdx) -> bytes: + return f"{json.dumps(document, indent=2)}\n".encode() + + +__all__ = [ + "dumps", +] diff --git a/src/python_sbom/_formats/_spdx.py b/src/python_sbom/_formats/_spdx.py new file mode 100644 index 0000000..69d63f5 --- /dev/null +++ b/src/python_sbom/_formats/_spdx.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import hashlib +import json +import re +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Final, NotRequired, TypedDict + +from python_sbom._model._names import SBOM_FILE, SBOM_REPOSITORY, property_prefix, sbom_property +from python_sbom.version import __version__ + +from ._json import dumps + +if TYPE_CHECKING: + from python_sbom._model._types import ( + Component, + Document, + ExternalReference, + License, + Metadata, + OrganizationalEntity, + Tools, + ) + +# SPDX 2.3 JSON names every CycloneDX 1.6 hash algorithm, but by names of its own +_HASH_ALGORITHMS: Final[dict[str, str]] = { + "MD5": "MD5", + "SHA-1": "SHA1", + "SHA-256": "SHA256", + "SHA-384": "SHA384", + "SHA-512": "SHA512", + "SHA3-256": "SHA3-256", + "SHA3-384": "SHA3-384", + "SHA3-512": "SHA3-512", + "BLAKE2b-256": "BLAKE2b-256", + "BLAKE2b-384": "BLAKE2b-384", + "BLAKE2b-512": "BLAKE2b-512", + "BLAKE3": "BLAKE3", +} +_LICENSE_REF: Final[re.Pattern[str]] = re.compile(r"LicenseRef-[A-Za-z0-9.-]+") + + +class _LegacyTool(TypedDict): + vendor: NotRequired[str] + name: NotRequired[str] + version: NotRequired[str] + + +class _Checksum(TypedDict): + algorithm: str + checksumValue: str + + +class _ExternalRef(TypedDict): + referenceCategory: str + referenceType: str + referenceLocator: str + + +class _Package(TypedDict): + SPDXID: str + name: str + downloadLocation: str + filesAnalyzed: bool + licenseConcluded: str + licenseDeclared: str + primaryPackagePurpose: str + versionInfo: NotRequired[str] + supplier: NotRequired[str] + checksums: NotRequired[list[_Checksum]] + homepage: NotRequired[str] + copyrightText: NotRequired[str] + summary: NotRequired[str] + comment: NotRequired[str] + externalRefs: NotRequired[list[_ExternalRef]] + licenseComments: NotRequired[str] + + +class _Relationship(TypedDict): + spdxElementId: str + relationshipType: str + relatedSpdxElement: str + + +class _CreationInfo(TypedDict): + created: str + creators: list[str] + + +class _ExtractedLicense(TypedDict): + licenseId: str + extractedText: str + name: str + + +class Spdx(TypedDict): + spdxVersion: str + dataLicense: str + SPDXID: str + name: str + documentNamespace: str + comment: str + creationInfo: _CreationInfo + packages: list[_Package] + relationships: list[_Relationship] + hasExtractedLicensingInfos: NotRequired[list[_ExtractedLicense]] + + +def to_spdx(document: Document) -> str: + return dumps(render_spdx(document, __version__)).decode() + + +def render_spdx(bom: Document, tool_version: str) -> Spdx: + metadata: Final[Metadata] = bom["metadata"] + root: Final[Component] = metadata["component"] + project: Final[str] = property_prefix(root["name"]) + recorded: Final[dict[str, str]] = {prop["name"]: prop.get("value", "") for prop in metadata.get("properties", [])} + name: Final[str] = f"{root['name']}-{root['version']}" if "version" in root else root["name"] + # without a serial number the content names the document, so rendering it twice gives the same namespace + source: Final[str] = ( + bom["serialNumber"] + if "serialNumber" in bom + else f"sha256:{hashlib.sha256(json.dumps(bom, sort_keys=True).encode()).hexdigest()}" + ) + repository: Final[str] = recorded.get(sbom_property(project, SBOM_REPOSITORY), "https://spdx.org/spdxdocs") + sbom_file: Final[str] = recorded.get(sbom_property(project, SBOM_FILE), "CycloneDX JSON") + tools, tool_creators = _tools(metadata) + components: Final[list[Component]] = _walk([root, *bom.get("components", []), *tools]) + packages: Final[list[_Package]] = [_package(component, f"{project}:") for component in components] + spdx: Final[Spdx] = { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": name, + "documentNamespace": f"{repository}/sboms/{name}-{source.removeprefix('urn:uuid:')}", + "comment": f"Rendered from the CycloneDX SBOM {source} ({sbom_file}), which also carries per-file hashes and" + " the build formulation.", + "creationInfo": _creation_info(metadata, tool_creators, tool_version), + "packages": packages, + "relationships": _relationships(bom, components, tools), + } + # SPDX requires the text of every LicenseRef a package declares; the metadata names them, it holds no text + references: Final[list[str]] = sorted({ + token for package in packages for token in _LICENSE_REF.findall(package["licenseDeclared"]) + }) + if references: + spdx["hasExtractedLicensingInfos"] = [ + {"licenseId": token, "extractedText": f"{token}, as named in package metadata", "name": token} + for token in references + ] + return spdx + + +def _tools(metadata: Metadata) -> tuple[list[Component], list[str]]: + # CycloneDX 1.5 lists the tools as components; earlier versions as a bare array, whose entries carry no bom-ref, + # so they become creators alone + tools: Final[Tools | list[_LegacyTool] | None] = metadata.get("tools") + if isinstance(tools, list): + return [], [_creator(name, tool.get("version")) for tool in tools if (name := tool.get("name"))] + components: Final[list[Component]] = [] if tools is None else tools.get("components", []) + return components, [ + _creator(tool["name"], tool.get("version")) for tool in components if tool["type"] == "application" + ] + + +def _creator(name: str, version: str | None) -> str: + return f"Tool: {name}" if version is None else f"Tool: {name}-{version}" + + +def _walk(components: list[Component]) -> list[Component]: + # iterative, so a deeply nested hostile document cannot exhaust the stack; file components mirror wheel RECORD + # entries, which carry no SHA-1 and so cannot become SPDX files + found: Final[list[Component]] = [] + pending: Final[list[Component]] = list(reversed(components)) + while pending: + if (component := pending.pop())["type"] != "file": + found.append(component) + pending.extend(reversed(component.get("components", []))) + return found + + +def _package(component: Component, property_prefix: str) -> _Package: + # a component carries either one SPDX expression, or the free-form names from License/classifier metadata plus the + # SPDX ids concluded from them + licenses: Final[list[License]] = component.get("licenses", []) + concluded: Final[list[str]] = [ + entry["license"]["id"] for entry in licenses if "license" in entry and "id" in entry["license"] + ] + package: Final[_Package] = { + "SPDXID": _spdx_id(component["bom-ref"]), + "name": component["name"], + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": " AND ".join(concluded) or "NOASSERTION", + "licenseDeclared": next((entry["expression"] for entry in licenses if "expression" in entry), "NOASSERTION"), + "primaryPackagePurpose": {"application": "APPLICATION", "library": "LIBRARY"}.get(component["type"], "OTHER"), + } + if version := component.get("version"): + package["versionInfo"] = version + if (supplier := component.get("supplier")) and (organization := supplier.get("name")): + package["supplier"] = f"Organization: {organization}" + checksums: Final[list[_Checksum]] = [ + {"algorithm": _HASH_ALGORITHMS[entry["alg"]], "checksumValue": entry["content"]} + for entry in component.get("hashes", []) + if entry["alg"] in _HASH_ALGORITHMS + ] + if checksums: + package["checksums"] = checksums + references: Final[list[ExternalReference]] = component.get("externalReferences", []) + if homepage := next((reference["url"] for reference in references if reference["type"] == "website"), None): + package["homepage"] = homepage + if copyright_text := component.get("copyright"): + package["copyrightText"] = copyright_text + if description := component.get("description"): + package["summary"] = description + if comment := "\n".join( + f"{prop['name']}: {prop.get('value', '')}" + for prop in component.get("properties", []) + if prop["name"].startswith(property_prefix) + ): + package["comment"] = comment + if purl := component.get("purl"): + package["externalRefs"] = [ + {"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": purl} + ] + if package["licenseDeclared"] == "NOASSERTION" and ( + names := [entry["license"]["name"] for entry in licenses if "license" in entry and "name" in entry["license"]] + ): + package["licenseComments"] = f"Declared in package metadata as: {'; '.join(names)}" + return package + + +def _spdx_id(bom_ref: str) -> str: + # SPDX ids allow letters, digits, "." and "-"; the digest keeps two refs that differ only in other characters, + # such as the markers of two conditional requirements, apart + readable: Final[str] = re.sub(r"[^A-Za-z0-9.]+", "-", bom_ref).strip("-") + return f"SPDXRef-{readable}-{hashlib.sha256(bom_ref.encode()).hexdigest()[:8]}" + + +def _creation_info(metadata: Metadata, tool_creators: list[str], tool_version: str) -> _CreationInfo: + timestamp: Final[str | None] = metadata.get("timestamp") + # SPDX requires a creation time; without the CycloneDX one, the time of this rendering is the true one + created: Final[datetime] = datetime.now(UTC) if timestamp is None else datetime.fromisoformat(timestamp) + supplier: Final[OrganizationalEntity | None] = metadata.get("supplier") + organization: Final[str] = "" if supplier is None else supplier.get("name", "") + creators: Final[list[str]] = [f"Organization: {organization}"] if organization else [] + creators.extend(tool_creators) + creators.append(f"Tool: python-sbom-{tool_version}") + return {"created": created.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "creators": creators} + + +def _relationships(bom: Document, components: list[Component], tools: list[Component]) -> list[_Relationship]: + root_id: Final[str] = _spdx_id(bom["metadata"]["component"]["bom-ref"]) + relationships: Final[list[_Relationship]] = [_relationship("SPDXRef-DOCUMENT", "DESCRIBES", root_id)] + relationships.extend( + _relationship(_spdx_id(parent["bom-ref"]), "CONTAINS", _spdx_id(child["bom-ref"])) + for parent in components + for child in parent.get("components", []) + if child["type"] != "file" + ) + relationships.extend( + _relationship(_spdx_id(entry["ref"]), "DEPENDS_ON", _spdx_id(dependency)) + for entry in bom.get("dependencies", []) + for dependency in entry.get("dependsOn", []) + ) + relationships.extend(_relationship(_spdx_id(tool["bom-ref"]), "BUILD_TOOL_OF", root_id) for tool in tools) + return relationships + + +def _relationship(element: str, kind: str, related: str) -> _Relationship: + return {"spdxElementId": element, "relationshipType": kind, "relatedSpdxElement": related} + + +__all__ = [ + "Spdx", + "render_spdx", + "to_spdx", +] diff --git a/src/python_sbom/_generate/__init__.py b/src/python_sbom/_generate/__init__.py new file mode 100644 index 0000000..8f1ddab --- /dev/null +++ b/src/python_sbom/_generate/__init__.py @@ -0,0 +1 @@ +"""The build pipeline: backends that adapt a build system, the content seam, and assembling the SBOM.""" diff --git a/src/python_sbom/_generate/_archive.py b/src/python_sbom/_generate/_archive.py new file mode 100644 index 0000000..6b28e6c --- /dev/null +++ b/src/python_sbom/_generate/_archive.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import hashlib +import tempfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Final + +from packageurl import PackageURL + +from python_sbom._collect._content import load_content_provider, provider_script +from python_sbom._collect._settings import settings_from_pyproject +from python_sbom._collect._source import read_bundled_wheel +from python_sbom._errors import WheelError +from python_sbom._model._components import ( + bundled_wheel_component, + component_from_metadata, + file_component_from_bytes, + script_tool, + vendored_dependencies, +) +from python_sbom._model._content import ProviderContext +from python_sbom._model._document import contributed_properties, formulation, timestamp, with_header +from python_sbom._model._identity import project_identity +from python_sbom._model._metadata import parse_metadata +from python_sbom._model._names import SBOM_FILE, sbom_property +from python_sbom._model._tools import tool_components +from python_sbom._model._types import Dependency +from python_sbom._wheel._archive import open_archive + +if TYPE_CHECKING: + from collections.abc import Sequence + + from python_sbom._model._content import ContentProvider + from python_sbom._model._identity import ProjectIdentity + from python_sbom._model._inputs import BuildFacts, Settings + from python_sbom._model._metadata import CoreMetadata + from python_sbom._model._types import Component, Document, Metadata + + +def describe_archive( + root: Path, + archive: Path, + *, + facts: BuildFacts, + content: ContentProvider | None = None, + build_scripts: Sequence[PurePosixPath] = (), +) -> Document: + # the archive's top-level .dist-info names the project; every other .dist-info marks a wheel bundled unpacked into + # its parent directory and every .whl member a bundled wheel; other files belong to the deepest distribution that + # holds them, or, at the top level, to the archive + settings = settings_from_pyproject(root, {}) + content = content or load_content_provider(settings.content_provider, root) + with tempfile.TemporaryDirectory() as directory: + held = _read_archive(root, archive, Path(directory), settings, content) + contributions = content.contributions(held.reader.context) + held.root["properties"] = list(contributed_properties(contributions.root_properties, held.project.property_prefix)) + if facts.revision is not None: + held.root["properties"].append({ + "name": f"{held.project.property_prefix}:vcs-commit", + "value": facts.revision.commit, + }) + components: Final[list[Component]] = [*held.components, *contributions.components] + tools: Final[list[Component]] = [ + *( + script_tool( + path, + hashlib.sha256((root / str(path)).read_bytes()).hexdigest(), + held.version, + None + if held.project.repository is None + else f"{held.project.repository}/blob/{settings.default_branch}/{path}", + ) + for path in [*build_scripts, *filter(None, [provider_script(content, root)])] + ), + *(environment := tool_components(facts))[0], + *contributions.tools, + ] + metadata: Final[Metadata] = { + "timestamp": timestamp(facts), + "lifecycles": [{"phase": "build"}], + "tools": {"components": tools}, + "component": held.root, + # names the document's file, which marks it as one python-sbom built, so validation checks it in full + "properties": [ + {"name": sbom_property(held.project.property_prefix, SBOM_FILE), "value": f"{archive.name}.cdx.json"} + ], + } + if settings.supplier is not None: + held.root["supplier"] = metadata["manufacturer"] = metadata["supplier"] = settings.supplier + return with_header( + { + "metadata": metadata, + "components": components, + "dependencies": [ + Dependency(ref=held.root["bom-ref"], dependsOn=[component["bom-ref"] for component in components]), + *vendored_dependencies(components), + *environment[1], + *contributions.dependencies, + ], + "formulation": [ + formulation("archive-build", f"build {archive.name} and this SBOM", tools, held.root["bom-ref"], facts) + ], + }, + held.project.serial_namespace, + ) + + +def _read_archive(root: Path, archive: Path, unpacked: Path, settings: Settings, content: ContentProvider) -> _Held: + # unpacked, so a content provider reads the archive's files the way it reads the project's + with open_archive(archive, archive.name) as handle: + members = sorted( + name for name in handle.namelist() if not name.endswith("/") and name != f"{archive.name}.cdx.json" + ) + handle.extractall(unpacked) + # each distribution by the directory that holds its .dist-info, the project's own at the top level + metadata = { + f"{name.rsplit('/', 2)[0]}/" if name.count("/") > 1 else "": parse_metadata( + (unpacked / name).read_text(encoding="utf-8") + ) + for name in members + if name.endswith(".dist-info/METADATA") + } + if (top := sum(name.count("/") == 1 and name.endswith(".dist-info/METADATA") for name in members)) != 1: + msg = f"{archive.name} holds {top} top-level .dist-info/METADATA, not the one naming the project" + raise WheelError(msg) + owned: Final[dict[str, list[str]]] = {prefix: [] for prefix in metadata} + loose: Final[list[str]] = [] + for name in members: + # the deepest distribution directory that holds the file; the project's own holds every other subdirectory + if owner := max((prefix for prefix in metadata if prefix and name.startswith(prefix)), key=len, default=""): + owned[owner].append(name) + elif "/" in name: + owned[""].append(name) + else: + loose.append(name) + project = project_identity(metadata[""], settings) + reader = _Reader( + unpacked=unpacked, + project=project, + settings=settings, + content=content, + context=ProviderContext(root=root, settings=settings, identity=project, artifact=unpacked), + ) + version = metadata[""].version + ref = PackageURL(type="generic", name=archive.name, version=version).to_string() + return _Held( + reader=reader, + project=reader.project, + version=version, + root={ + "type": "application", + "bom-ref": ref, + "name": archive.name, + "version": version, + "purl": ref, + "components": [reader.file(ref, name) for name in loose], + }, + components=[reader.distribution(prefix, metadata[prefix], owned[prefix]) for prefix in sorted(metadata)], + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _Reader: + unpacked: Path + project: ProjectIdentity + settings: Settings + content: ContentProvider + context: ProviderContext + + def distribution(self, prefix: str, metadata: CoreMetadata, names: list[str]) -> Component: + component = component_from_metadata(metadata) + if prefix: + # a wheel bundled unpacked: the directory tells copies of one release apart, as the path does for a .whl + directory = PurePosixPath(prefix.rstrip("/")) + info = self.content.bundled_wheel(self.context, directory) + component["bom-ref"] = f"bundled:{directory}" + if info.supplier is not None: + component["supplier"] = info.supplier + component["properties"] = [ + {"name": f"{self.project.property_prefix}:bundled-wheel", "value": str(directory)}, + *info.properties, + *component["properties"], + ] + component["components"] = [ + self.wheel(name) if name.endswith(".whl") else self.file(component["bom-ref"], name) for name in names + ] + return component + + def wheel(self, name: str) -> Component: + info = self.content.bundled_wheel(self.context, PurePosixPath(name)) + return bundled_wheel_component( + read_bundled_wheel((self.unpacked / name).read_bytes(), name), + self.project.property_prefix, + info.properties, + info.supplier, + ) + + def file(self, parent: str, name: str) -> Component: + return file_component_from_bytes(parent, name, (self.unpacked / name).read_bytes()) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _Held: + reader: _Reader + project: ProjectIdentity + version: str + root: Component + components: list[Component] + + +__all__ = [ + "describe_archive", +] diff --git a/src/python_sbom/_generate/_embed.py b/src/python_sbom/_generate/_embed.py new file mode 100644 index 0000000..66cf3b0 --- /dev/null +++ b/src/python_sbom/_generate/_embed.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING + +from python_sbom._collect._content import load_content_provider, provider_script +from python_sbom._collect._facts import collect_facts, commit_revision, default_revision +from python_sbom._collect._settings import settings_from_pyproject +from python_sbom._collect._source import read_source_tree, wheel_request +from python_sbom._model._content import ProviderContext +from python_sbom._model._identity import project_identity +from python_sbom._wheel._archive import member_digest, open_archive, write_sbom + +from ._pipeline import build_sbom + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from python_sbom._collect._facts import RevisionReader + from python_sbom._model._content import ContentProvider + from python_sbom._model._inputs import BuildFacts, SbomRequest + + +def embed_sbom( + wheel: Path, + root: Path, + *, + content: ContentProvider | None, + commit: str | None = None, + build_env: Sequence[str] | None, +) -> str: + settings = settings_from_pyproject(root, {}) + content = content or load_content_provider(settings.content_provider, root) + scripts = tuple(filter(None, [provider_script(content, root)])) + return embed_into( + wheel, + wheel_request(wheel, root, settings, scripts), + content, + collect_facts(root, revision_reader(commit), path=build_env), + ) + + +def embed_into(wheel: Path, request: SbomRequest, content: ContentProvider, facts: BuildFacts) -> str: + source = read_source_tree(request) + with open_archive(wheel, str(wheel)) as archive: + inside = {member_digest(archive, name, "sha256"): name for name in archive.namelist() if name.endswith(".whl")} + # a bundled wheel the built wheel holds is named by its path in there, as an archive SBOM names it, so both + # documents give one wheel the same identity + source = dataclasses.replace( + source, + bundled=[ + dataclasses.replace(bundled, path=inside.get(bundled.sha256, bundled.path)) for bundled in source.bundled + ], + ) + project = project_identity(request.metadata, request.settings) + context = ProviderContext(root=request.root, settings=request.settings, identity=project, artifact=wheel) + sbom = build_sbom(request, source, facts, content, context) + return write_sbom(wheel, sbom.file_name, sbom.content, facts.epoch) + + +def revision_reader(commit: str | None) -> RevisionReader: + if commit is None: + return default_revision + revision = commit_revision(commit, "--commit") + return lambda _root: revision + + +__all__ = [ + "embed_into", + "embed_sbom", + "revision_reader", +] diff --git a/src/python_sbom/_generate/_pipeline.py b/src/python_sbom/_generate/_pipeline.py new file mode 100644 index 0000000..9a751ef --- /dev/null +++ b/src/python_sbom/_generate/_pipeline.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +from python_sbom._formats._json import dumps +from python_sbom._model._bom import describe +from python_sbom._model._names import sbom_file_name + +if TYPE_CHECKING: + from python_sbom._model._content import ContentProvider, ProviderContext + from python_sbom._model._inputs import BuildFacts, SbomRequest, SourceTree + from python_sbom._model._types import Document + + +def build_sbom( + request: SbomRequest, source: SourceTree, facts: BuildFacts, content: ContentProvider, context: ProviderContext +) -> Sbom: + return Sbom( + file_name=sbom_file_name(request.metadata.name), + document=describe( + request, + source, + facts, + {wheel.path: content.bundled_wheel(context, PurePosixPath(wheel.path)) for wheel in source.bundled}, + content.contributions(context), + ), + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Sbom: + file_name: str + document: Document + + @property + def content(self) -> bytes: + return dumps(self.document) + + +__all__ = [ + "Sbom", + "build_sbom", +] diff --git a/src/python_sbom/_model/__init__.py b/src/python_sbom/_model/__init__.py new file mode 100644 index 0000000..40aeade --- /dev/null +++ b/src/python_sbom/_model/__init__.py @@ -0,0 +1 @@ +"""The CycloneDX document model: its types, its inputs and the pure functions that build it.""" diff --git a/src/python_sbom/_model/_bom.py b/src/python_sbom/_model/_bom.py new file mode 100644 index 0000000..530168b --- /dev/null +++ b/src/python_sbom/_model/_bom.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final +from urllib.parse import urlsplit + +from packaging.requirements import Requirement + +from ._components import bundled_wheel_component, component_from_metadata, script_tool, vendored_dependencies +from ._document import contributed_properties, formulation, timestamp, with_header +from ._identity import project_identity +from ._markers import applies_without_extras +from ._names import SBOM_FILE, SBOM_REPOSITORY, purl, pypi_url, sbom_file_name, sbom_property +from ._tools import tool_components + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ._identity import ProjectIdentity + from ._inputs import BuildFacts, BundledWheelInfo, Contributions, SbomRequest, Settings, SourceTree + from ._types import Component, Document, ExternalReference, Metadata, Property + + +def describe( + request: SbomRequest, + source: SourceTree, + facts: BuildFacts, + bundled_info: Mapping[str, BundledWheelInfo], + contributions: Contributions, +) -> Document: + project = project_identity(request.metadata, request.settings) + root = _root_component(request, source, facts, project, contributions) + bundled = [ + bundled_wheel_component( + wheel, + project.property_prefix, + bundled_info[wheel.path].properties, + bundled_info[wheel.path].supplier, + ) + for wheel in source.bundled + ] + components = [ + *bundled, + # hatchling repeats a Requires-Dist when a hook re-adds a dependency, and each bom-ref must be unique + *( + _declared_dependency(text, requirement, project.property_prefix) + for text in dict.fromkeys(request.metadata.values("requires_dist")) + if applies_without_extras(requirement := Requirement(text)) + ), + *contributions.components, + ] + environment, tool_dependencies = tool_components(facts) + tools = [ + *( + script_tool( + script.path, + script.sha256, + request.metadata.version, + _file_link(project, request.settings, str(script.path)), + ) + for script in source.build_scripts + ), + *environment, + *contributions.tools, + ] + return with_header( + { + "metadata": _metadata(root, tools, request, project, facts), + "components": components, + "dependencies": [ + {"ref": root["bom-ref"], "dependsOn": [component["bom-ref"] for component in components]}, + *vendored_dependencies(bundled), + *tool_dependencies, + *contributions.dependencies, + ], + # transitive runtime dependencies are unknown until install time + "compositions": [{"aggregate": "incomplete", "dependencies": [root["bom-ref"]]}], + "formulation": [formulation("wheel-build", "build the wheel and this SBOM", tools, root["bom-ref"], facts)], + }, + project.serial_namespace, + ) + + +def _root_component( + request: SbomRequest, source: SourceTree, facts: BuildFacts, project: ProjectIdentity, contributions: Contributions +) -> Component: + metadata, settings = request.metadata, request.settings + name, version = metadata.name, metadata.version + root = component_from_metadata(metadata) + root["type"] = "application" + if settings.supplier is not None: + root["supplier"] = settings.supplier + if source.copyright is not None: + root["copyright"] = source.copyright + references: Final[list[ExternalReference]] = [ + *root["externalReferences"], + {"type": "distribution", "url": pypi_url(name, version)}, + {"type": "attestation", "url": f"https://pypi.org/integrity/{name}/{version}/{request.wheel_name}/provenance"}, + ] + if project.repository and urlsplit(project.repository).netloc == "github.com": + references.extend([ + {"type": "security-contact", "url": f"{project.repository}/security/policy"}, + {"type": "advisories", "url": f"{project.repository}/security/advisories"}, + ]) + if (license_files := metadata.values("license_files")) and ( + link := _file_link(project, settings, license_files[0]) + ): + references.append({"type": "license", "url": link}) + properties: Final[list[Property]] = [ + *root["properties"], + *contributed_properties(contributions.root_properties, project.property_prefix), + ] + if (revision := facts.revision) is not None: + if project.repository: + comment = "source revision, with uncommitted changes" if revision.dirty else "exact source revision" + references.append({ + "type": "vcs", + "url": f"{project.repository}/tree/{revision.commit}", + "comment": comment, + }) + properties.append({"name": f"{project.property_prefix}:vcs-commit", "value": revision.commit}) + if revision.dirty: + properties.append({"name": f"{project.property_prefix}:vcs-dirty", "value": "true"}) + root["externalReferences"] = references + root["properties"] = properties + return root + + +def _declared_dependency(text: str, requirement: Requirement, namespace: str) -> Component: + component: Final[Component] = { + "type": "library", + # the same distribution can be declared more than once with different markers, so the purl is not unique + "bom-ref": f"requires-dist:{text}", + "name": requirement.name, + "purl": purl(requirement.name), + "externalReferences": [{"type": "distribution", "url": pypi_url(requirement.name)}], + "properties": [{"name": f"{namespace}:requires-dist", "value": text}], + } + if requirement.marker is not None: + component["properties"].append({"name": f"{namespace}:environment-marker", "value": str(requirement.marker)}) + return component + + +def _file_link(project: ProjectIdentity, settings: Settings, path: str) -> str | None: + return None if project.repository is None else f"{project.repository}/blob/{settings.default_branch}/{path}" + + +def _metadata( + root: Component, tools: list[Component], request: SbomRequest, project: ProjectIdentity, facts: BuildFacts +) -> Metadata: + settings = request.settings + metadata: Final[Metadata] = { + "timestamp": timestamp(facts), + "lifecycles": [{"phase": "build"}], + "tools": {"components": tools}, + "component": root, + } + if settings.supplier is not None: + metadata["manufacturer"] = metadata["supplier"] = settings.supplier + if "authors" in root: + metadata["authors"] = root["authors"] + if "licenses" in root: + metadata["licenses"] = root["licenses"] + properties: Final[list[Property]] = [ + {"name": sbom_property(project.property_prefix, "sbom:scope"), "value": _scope(settings)}, + {"name": sbom_property(project.property_prefix, SBOM_FILE), "value": sbom_file_name(request.metadata.name)}, + ] + if project.repository is not None: + properties.append({ + "name": sbom_property(project.property_prefix, SBOM_REPOSITORY), + "value": project.repository, + }) + metadata["properties"] = properties + return metadata + + +def _scope(settings: Settings) -> str: + declared = ( + "the direct runtime dependencies declared in the wheel's core metadata, which carry no version because the" + " installer resolves them" + ) + if directories := " and ".join(sorted({str(PurePosixPath(pattern).parent) for pattern in settings.bundled_wheels})): + return f"Components are the wheels bundled as data files under {directories} and {declared}." + return f"Components are {declared}." + + +__all__ = [ + "describe", +] diff --git a/src/python_sbom/_model/_components.py b/src/python_sbom/_model/_components.py new file mode 100644 index 0000000..ae8e465 --- /dev/null +++ b/src/python_sbom/_model/_components.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import hashlib +import re +from email.utils import getaddresses +from itertools import starmap +from typing import TYPE_CHECKING, Final +from urllib.parse import urlsplit + +from ._inputs import RecordRow +from ._licenses import licenses +from ._names import property_prefix, purl, pypi_url, reference_type, tool_ref +from ._types import Dependency + +if TYPE_CHECKING: + from pathlib import PurePosixPath + + from ._inputs import BundledWheel + from ._metadata import CoreMetadata + from ._types import Component, Contact, ExternalReference, Identity, OrganizationalEntity, Property + + +def bundled_wheel_component( + wheel: BundledWheel, namespace: str, properties: list[Property], supplier: OrganizationalEntity | None +) -> Component: + component = component_from_metadata(wheel.metadata) + # the purl names the package; the path tells apart copies of one release bundled in two places + component["bom-ref"] = f"bundled:{wheel.path}" + if supplier is not None: + component["supplier"] = supplier + component["hashes"] = [{"alg": "SHA-256", "content": wheel.sha256}] + component["externalReferences"] = [ + *component["externalReferences"], + {"type": "distribution", "url": pypi_url(wheel.metadata.name, wheel.metadata.version)}, + ] + component["properties"] = [ + {"name": f"{namespace}:bundled-wheel", "value": wheel.path}, + *properties, + *component["properties"], + ] + component["evidence"] = { + "identity": [ + _evidence("purl", "manifest-analysis", wheel.metadata_name), + _evidence("hash", "hash-comparison", wheel.sha256), + ] + } + vendored: Final[dict[str, Component]] = {} + for source, metadata in wheel.vendored: + child = component_from_metadata(metadata) + child["bom-ref"] = f"{component['bom-ref']}#vendored/{child['bom-ref']}" + child["properties"] = [*child["properties"], {"name": f"{namespace}:vendored-manifest", "value": source}] + child["evidence"] = {"identity": [_evidence("purl", "manifest-analysis", source)]} + # a vendored .dist-info comes after pip's manifest line naming the same package, so its metadata wins; keyed on + # the PEP 503 name, since the manifest and the .dist-info may spell it differently + vendored[f"{property_prefix(child['name'])}=={child['version']}"] = child + component["components"] = [ + *(file_component(component["bom-ref"], row) for row in wheel.files), + *(vendored[key] for key in sorted(vendored)), + ] + return component + + +def component_from_metadata(metadata: CoreMetadata) -> Component: + name, version = metadata.name, metadata.version + component: Final[Component] = {"type": "library", "bom-ref": purl(name, version), "name": name, "version": version} + component["purl"] = component["bom-ref"] + if summary := metadata.first("summary"): + component["description"] = summary + # maintainers are who users reach today, so they win over the original authors when both are named + for names, addresses in (("maintainer", "maintainer_email"), ("author", "author_email")): + named = (_contact(name, "") for name in metadata.values(names)) + if people := [ + contact for contact in (*named, *starmap(_contact, getaddresses(metadata.values(addresses)))) if contact + ]: + component["authors"] = people + break + if declared := licenses(metadata): + component["licenses"] = declared + component["externalReferences"] = _project_references(metadata) + # core metadata headers copied verbatim, so a reader sees what the distribution declared + component["properties"] = [ + {"name": f"python:{header}", "value": value} + for header, values in ( + ("requires-python", metadata.values("requires_python")), + ("requires-dist", metadata.values("requires_dist")), + ("provides-extra", metadata.values("provides_extra")), + ("classifier", metadata.values("classifiers")), + ("keywords", metadata.keywords()), + ) + for value in values + ] + return component + + +def _contact(name: str, email: str) -> Contact: + if email and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): + # an address the schema rejects as an email, such as "jane at example.com", is kept as the name + return {"name": " ".join(part for part in (name, email) if part)} + contact: Final[Contact] = {} + if name: + contact["name"] = name + if email: + contact["email"] = email + return contact + + +def _project_references(metadata: CoreMetadata) -> list[ExternalReference]: + # a home page that is not an http(s) URL, such as UNKNOWN, is left out + references: Final[list[ExternalReference]] = [] + if (home_page := metadata.first("home_page")) and urlsplit(home_page).scheme in {"http", "https"}: + references.append({"type": "website", "url": home_page}) + references.extend( + {"type": reference_type(label), "url": url, "comment": f"Project-URL: {label}"} + for label, url in metadata.project_urls() + ) + return references + + +def _evidence(field: str, technique: str, value: str) -> Identity: + return {"field": field, "confidence": 1, "methods": [{"technique": technique, "confidence": 1, "value": value}]} + + +def file_component(parent_ref: str, row: RecordRow) -> Component: + return { + "type": "file", + "bom-ref": f"{parent_ref}#{row.path}", + "name": row.path, + "hashes": [{"alg": row.algorithm, "content": row.digest}], + "properties": [{"name": "size", "value": row.size}], + } + + +def vendored_dependencies(components: list[Component]) -> list[Dependency]: + # each bundled wheel depends on the packages it vendors, wherever it sits in the document + found: Final[list[Dependency]] = [] + pending = list(components) + while pending: + component = pending.pop(0) + children = component.get("components", []) + if component["bom-ref"].startswith("bundled:") and ( + vendored := [ + child["bom-ref"] + for child in children + if child["type"] == "library" and "#vendored/" in child["bom-ref"] + ] + ): + found.append(Dependency(ref=component["bom-ref"], dependsOn=vendored)) + pending.extend(children) + return found + + +def file_component_from_bytes(parent_ref: str, name: str, content: bytes) -> Component: + return file_component( + parent_ref, + RecordRow(path=name, algorithm="SHA-256", digest=hashlib.sha256(content).hexdigest(), size=str(len(content))), + ) + + +def script_tool(path: PurePosixPath, sha256: str, version: str, link: str | None) -> Component: + tool: Final[Component] = { + "type": "application", + "bom-ref": tool_ref(str(path)), + "name": path.name, + "version": version, + "hashes": [{"alg": "SHA-256", "content": sha256}], + } + if link is not None: + tool["externalReferences"] = [{"type": "vcs", "url": link}] + return tool + + +__all__ = [ + "bundled_wheel_component", + "component_from_metadata", + "file_component", + "file_component_from_bytes", + "script_tool", + "vendored_dependencies", +] diff --git a/src/python_sbom/_model/_content.py b/src/python_sbom/_model/_content.py new file mode 100644 index 0000000..d712d97 --- /dev/null +++ b/src/python_sbom/_model/_content.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import zipfile +from typing import TYPE_CHECKING + +from ._inputs import BundledWheelInfo, Contributions + +if TYPE_CHECKING: + from pathlib import Path, PurePosixPath + + from ._identity import ProjectIdentity + from ._inputs import Settings + + +class ProviderContext: + """ + What a content provider may use about the build it contributes to. + + :param root: the project directory + :param settings: the project's options + :param identity: how the project's SBOMs name it, including the prefix of its properties + :param artifact: the built wheel, or the directory an archive is unpacked into, which :meth:`read` reads + :ivar root: the project directory + :ivar settings: the project's options + :ivar identity: how the project's SBOMs name it, including the prefix of its properties + + """ + + def __init__(self, *, root: Path, settings: Settings, identity: ProjectIdentity, artifact: Path) -> None: + self.root = root + self.settings = settings + self.identity = identity + self._artifact = artifact + + def read(self, path: PurePosixPath) -> bytes: + """ + Read a file of the artifact being described: a member of the built wheel or of the archive. + + :param path: the file's path inside the artifact, as a bundled wheel's path is given + :returns: the file's bytes + :raises KeyError: when the artifact holds no such file + + """ + if self._artifact.is_dir(): + if not (target := self._artifact / str(path)).is_file(): + raise KeyError(str(path)) + return target.read_bytes() + with zipfile.ZipFile(self._artifact) as archive: + return archive.read(str(path)) + + +class ContentProvider: + """ + Adds what a project knows beyond its metadata to its SBOM; subclass it and override what the project needs. + + Name a subclass in the ``content-provider`` option, which the hook, ``python-sbom embed`` and ``python-sbom + archive`` load, or return an instance from :meth:`python_sbom.hatch.SbomBuildHook.content_provider`. When the class + lives in the project, the SBOM lists its module among the build tools. + + """ + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: # ruff: ignore[unused-method-argument, no-self-use] # the no-op default + """ + Describe what the project does with a wheel it bundles, as a file or unpacked into a directory. + + :param context: the project and the artifact being described + :param wheel: the wheel's path inside the artifact + :returns: its extra properties, and whether the project's supplier supplies it; neither by default + + """ + return BundledWheelInfo() + + def contributions(self, context: ProviderContext) -> Contributions: # ruff: ignore[unused-method-argument, no-self-use] # the no-op default + """ + Add components, their dependencies, root properties and build tools, before references and the serial derive. + + :param context: the project and the artifact being described + :returns: the contributions; none by default + + """ + return Contributions() + + +__all__ = [ + "ContentProvider", + "ProviderContext", +] diff --git a/src/python_sbom/_model/_document.py b/src/python_sbom/_model/_document.py new file mode 100644 index 0000000..ced9142 --- /dev/null +++ b/src/python_sbom/_model/_document.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import uuid +from collections import Counter +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Final + +from python_sbom._errors import SbomError + +if TYPE_CHECKING: + from ._inputs import BuildFacts + from ._types import Body, Component, Document, Formula, Property + + +def with_header(body: Body, namespace: uuid.UUID) -> Document: + # a provider contributes refs of its own, so the document is checked before it is written rather than by validate + refs: Final[Counter[str]] = Counter() + pending: Final[list[Component]] = [ + body["metadata"]["component"], + *body["components"], + *body["metadata"]["tools"]["components"], + ] + while pending: + component = pending.pop() + refs[component["bom-ref"]] += 1 + pending.extend(component.get("components", [])) + if repeated := sorted(ref for ref, count in refs.items() if count > 1): + msg = f"bom-ref values repeat: {', '.join(repeated)}" + raise SbomError(msg) + named = {ref for entry in body["dependencies"] for ref in (entry["ref"], *entry.get("dependsOn", []))} + if unknown := sorted(named - refs.keys()): + msg = f"dependencies name bom-refs no component has: {', '.join(unknown)}" + raise SbomError(msg) + return { + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{uuid.uuid5(namespace, json.dumps(body, sort_keys=True))}", + "version": 1, + **body, + } + + +def contributed_properties(properties: list[Property], prefix: str) -> list[Property]: + if claimed := sorted(prop["name"] for prop in properties if prop["name"].startswith(f"{prefix}:")): + msg = f"contributed root properties {', '.join(claimed)} use the {prefix}: prefix python-sbom writes" + raise SbomError(msg) + return properties + + +def formulation(uid: str, name: str, tools: list[Component], output_ref: str, facts: BuildFacts) -> Formula: + formula: Formula = { + "bom-ref": f"formula:{uid}", + "workflows": [ + { + "bom-ref": f"workflow:{uid}", + "uid": uid, + "name": name, + "taskTypes": ["build"], + "resourceReferences": [{"ref": tool["bom-ref"]} for tool in tools], + "outputs": [{"type": "artifact", "resource": {"ref": output_ref}}], + } + ], + } + if facts.epoch_from_environment: + formula["workflows"][0]["inputs"] = [ + {"environmentVars": [{"name": "SOURCE_DATE_EPOCH", "value": str(facts.epoch)}]} + ] + return formula + + +def timestamp(facts: BuildFacts) -> str: + return datetime.fromtimestamp(facts.epoch, tz=UTC).isoformat() + + +__all__ = [ + "contributed_properties", + "formulation", + "timestamp", + "with_header", +] diff --git a/src/python_sbom/_model/_identity.py b/src/python_sbom/_model/_identity.py new file mode 100644 index 0000000..c470c8f --- /dev/null +++ b/src/python_sbom/_model/_identity.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ._names import property_prefix, reference_type + +if TYPE_CHECKING: + from ._inputs import Settings + from ._metadata import CoreMetadata + + +def project_identity(metadata: CoreMetadata, settings: Settings) -> ProjectIdentity: + project = property_prefix(metadata.name) + repository = settings.repository or next( + (url.rstrip("/") for label, url in metadata.project_urls() if reference_type(label) == "vcs"), None + ) + base = repository or f"https://pypi.org/project/{project}" + return ProjectIdentity( + property_prefix=project, + repository=repository, + serial_namespace=uuid.uuid5(uuid.NAMESPACE_URL, f"{base}/sboms"), + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectIdentity: + """ + How every SBOM of a project names it. + + :param property_prefix: the project's normalized name, which starts the names of the properties python-sbom writes, + such as ``virtualenv`` in ``virtualenv:bundled-wheel`` + :param repository: the repository URL, from the options or the first ``Source``, ``Repository`` or ``GitHub`` + project URL, or ``None`` + :param serial_namespace: the UUID namespace of the serial numbers of the project's SBOMs + + """ + + property_prefix: str + repository: str | None + serial_namespace: uuid.UUID + + +__all__ = [ + "ProjectIdentity", + "project_identity", +] diff --git a/src/python_sbom/_model/_inputs.py b/src/python_sbom/_model/_inputs.py new file mode 100644 index 0000000..42af1ae --- /dev/null +++ b/src/python_sbom/_model/_inputs.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path, PurePosixPath + + from ._metadata import CoreMetadata + from ._types import Component, Dependency, OrganizationalEntity, Property + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RecordRow: + path: str + algorithm: str + digest: str + size: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Interpreter: + name: str + version: str + implementation: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class InstalledDistribution: + name: str + metadata: CoreMetadata + files: list[RecordRow] + depends_on: list[str] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Revision: + commit: str + dirty: bool + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BuildFacts: + epoch: int + epoch_from_environment: bool + revision: Revision | None + interpreter: Interpreter + distributions: list[InstalledDistribution] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Settings: + """ + The project's options, from ``[tool.python-sbom]``. + + :param supplier: the organization that supplies the project, or ``None`` to name none + :param repository: the repository URL, or ``None`` to take the first ``Source``, ``Repository`` or ``GitHub`` + project URL + :param default_branch: the branch in links to files in the repository + :param bundled_wheels: glob patterns, relative to the project directory, of wheels the project ships as data files + :param content_provider: ``module:attribute`` of the content provider, or ``None`` + + """ + + supplier: OrganizationalEntity | None + repository: str | None + default_branch: str + bundled_wheels: tuple[str, ...] + content_provider: str | None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class SbomRequest: + root: Path + metadata: CoreMetadata + wheel_name: str + settings: Settings + build_scripts: tuple[PurePosixPath, ...] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BundledWheel: + path: str + sha256: str + metadata_name: str + metadata: CoreMetadata + files: list[RecordRow] + vendored: list[tuple[str, CoreMetadata]] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BuildScript: + path: PurePosixPath + sha256: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class SourceTree: + copyright: str | None + bundled: list[BundledWheel] + build_scripts: list[BuildScript] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BundledWheelInfo: + """ + What a project adds to the component of one bundled wheel. + + Both fields default to empty, so a provider names only what it adds. + + :param properties: properties after ``:bundled-wheel``, before those from the wheel's metadata + :param supplier: the wheel's supplier, or ``None`` to name none; only the project decides a bundled wheel's + supplier, never the wheel's own metadata + + """ + + properties: list[Property] = field(default_factory=list) + supplier: OrganizationalEntity | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Contributions: + """ + What a project adds to the document beyond its bundled wheels, merged before references and the serial are derived. + + Each field defaults to empty, so a provider names only what it adds. The build fails when a ``bom-ref`` repeats or + a dependency names one that does not exist. + + :param components: extra top-level components, such as vendored C libraries; the root depends on each, and a + component's own parts nest under its ``components`` + :param dependencies: extra dependency entries, such as the dependencies of those components + :param root_properties: extra properties of the root component; their names must not start with the project's + prefix, which python-sbom's own properties use + :param tools: extra build tools + + """ + + components: list[Component] = field(default_factory=list) + dependencies: list[Dependency] = field(default_factory=list) + root_properties: list[Property] = field(default_factory=list) + tools: list[Component] = field(default_factory=list) + + +__all__ = [ + "BuildFacts", + "BuildScript", + "BundledWheel", + "BundledWheelInfo", + "Contributions", + "InstalledDistribution", + "Interpreter", + "RecordRow", + "Revision", + "SbomRequest", + "Settings", + "SourceTree", +] diff --git a/src/python_sbom/_model/_licenses.py b/src/python_sbom/_model/_licenses.py new file mode 100644 index 0000000..17ff8a1 --- /dev/null +++ b/src/python_sbom/_model/_licenses.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Final + +from packaging.licenses import InvalidLicenseExpression, canonicalize_license_expression + +if TYPE_CHECKING: + from ._metadata import CoreMetadata + from ._types import License + + +def licenses(metadata: CoreMetadata) -> list[License]: + # a License-Expression is taken as declared; otherwise each License field and License classifier name is + # declared, and the SPDX ids they map to are added as concluded + if expression := metadata.first("license_expression"): + try: + return [{"expression": canonicalize_license_expression(expression), "acknowledgement": "declared"}] + except InvalidLicenseExpression: + # an expression SPDX rejects, such as "MIT WITH GPL-3.0-only", stays as the name the metadata gives + return [{"license": {"name": expression, "acknowledgement": "declared"}}] + names = [entry.rsplit(" :: ", 1)[-1] for entry in metadata.values("classifiers") if entry.startswith("License :: ")] + if (declared := metadata.first("license")) and "\n" not in declared: + names.append(declared) + found: Final[list[License]] = [{"license": {"name": name, "acknowledgement": "declared"}} for name in names] + # a license policy judges SPDX ids only, so the names stay bare when any one of them does not resolve, and the + # policy reports that instead of judging a partial set + identifiers = [identifier for name in names if (identifier := _spdx_license(name)) is not None] + if len(identifiers) == len(names): + found.extend( + {"license": {"id": identifier, "acknowledgement": "concluded"}} for identifier in dict.fromkeys(identifiers) + ) + return found + + +def _spdx_license(name: str) -> str | None: + # only the license classifiers that name exactly one SPDX license; "BSD License" or "Apache Software License" leave + # the version or variant open + if identifier := { + "ISC License (ISCL)": "ISC", + "MIT License": "MIT", + "MIT No Attribution License (MIT-0)": "MIT-0", + "Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0", + "Python Software Foundation License": "PSF-2.0", + "The Unlicense (Unlicense)": "Unlicense", + }.get(name): + return identifier + try: + canonical = canonicalize_license_expression(name) + except InvalidLicenseExpression: + return None + # a CycloneDX license id holds one entry of the SPDX license list, never an expression or a LicenseRef + return canonical if re.fullmatch(r"[A-Za-z0-9.-]+", canonical) and not canonical.startswith("LicenseRef-") else None + + +__all__ = [ + "licenses", +] diff --git a/src/python_sbom/_model/_markers.py b/src/python_sbom/_model/_markers.py new file mode 100644 index 0000000..e2513ea --- /dev/null +++ b/src/python_sbom/_model/_markers.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from packaging._parser import Op, Value, Variable # ruff: ignore[import-private-name] # packaging exposes the parsed marker tree only privately +from packaging.markers import Marker, UndefinedComparison + +if TYPE_CHECKING: + from packaging._parser import MarkerAtom, MarkerList # see above + from packaging.requirements import Requirement + + +def applies_without_extras(requirement: Requirement) -> bool: + # the conditions on extra are decided with no extra requested and every other condition stays unknown, so the + # requirement applies unless its extra conditions alone rule it out; dep-logic reduces markers the same way, but it + # rejects extra comparisons other than == and != through assert, which python -O strips, and on import it replaces + # packaging's marker grammar for the whole build process + if requirement.marker is None: + return True + return _extra_verdict(requirement.marker._markers) is not False # ruff: ignore[private-member-access] # the tree, see the import above + + +def _extra_verdict(markers: MarkerList | MarkerAtom) -> bool | None: + # three-valued, with "and" binding tighter than "or", as packaging's own evaluation groups them + groups: list[list[bool | None]] = [[]] + for item in markers: + if item == "or": + groups.append([]) + elif isinstance(item, list): + groups[-1].append(_extra_verdict(item)) + elif isinstance(item, tuple): + match item: + case Variable(value="extra"), Op(value=operator), Value(value=value): + groups[-1].append(_no_extra_verdict(f"extra {operator} {value!r}")) + case Value(value=value), Op(value=operator), Variable(value="extra"): + groups[-1].append(_no_extra_verdict(f"{value!r} {operator} extra")) + case _: + groups[-1].append(None) + # ranked False < unknown < True: "and" takes the lowest verdict of a group, "or" the highest of the groups + verdicts: Final[tuple[bool | None, ...]] = (False, None, True) + return verdicts[max(min(map(verdicts.index, group), default=2) for group in groups)] + + +def _no_extra_verdict(condition: str) -> bool | None: + try: + return Marker(condition).evaluate({"extra": ""}) + # extra compared as a version, such as extra ~= "1.0", has no answer, so it stays unknown + except UndefinedComparison: + return None + + +__all__ = [ + "applies_without_extras", +] diff --git a/src/python_sbom/_model/_metadata.py b/src/python_sbom/_model/_metadata.py new file mode 100644 index 0000000..33edd61 --- /dev/null +++ b/src/python_sbom/_model/_metadata.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, Literal, TypeAlias + +from packaging.metadata import parse_email + +if TYPE_CHECKING: + from packaging.metadata import RawMetadata + +# the fields python-sbom reads, by their key in packaging's RawMetadata +Field: TypeAlias = Literal[ + "author", + "author_email", + "classifiers", + "home_page", + "license", + "license_expression", + "license_files", + "maintainer", + "maintainer_email", + "name", + "provides_extra", + "requires_dist", + "requires_python", + "summary", + "version", +] + +# unparsed keys a field by its header name, lowercased, which is the RawMetadata key with dashes except for these +_HEADERS: Final[dict[str, str]] = {"classifiers": "classifier", "license_files": "license-file"} + + +def parse_metadata(text: str) -> CoreMetadata: + # not validated: the SBOM describes a distribution whose metadata breaks the specification as well, as written + raw, unparsed = parse_email(text) + return CoreMetadata(raw=raw, unparsed=unparsed) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CoreMetadata: + raw: RawMetadata + unparsed: dict[str, list[str]] + + @property + def name(self) -> str: + return self.first("name") or "" + + def first(self, field: Field) -> str | None: + return next(iter(self.values(field)), None) + + def values(self, field: Field) -> list[str]: + # packaging keeps a field it cannot take as declared, such as a single-use field given twice, out of raw and + # its values as written in unparsed, from where they are still described + match self.raw.get(field): + case str() as value: + return [value] + case list() as values: + return values + return self.unparsed.get(_HEADERS.get(field, field.replace("_", "-")), []) + + @property + def version(self) -> str: + return self.first("version") or "" + + def keywords(self) -> list[str]: + # packaging splits Keywords at commas, so they are joined back into the one value the distribution declared + if "keywords" in self.raw: + return [",".join(self.raw["keywords"])] + return self.unparsed.get("keywords", []) + + def project_urls(self) -> list[tuple[str, str]]: + # a label given twice sends every Project-URL to unparsed, where each splits at its first comma as packaging + # splits them; an entry without a URL names nothing to reference + pairs: Final[list[tuple[str, str]]] = ( + list(self.raw["project_urls"].items()) + if "project_urls" in self.raw + else [ + (label.strip(), url.strip()) + for label, _, url in (entry.partition(",") for entry in self.unparsed.get("project-url", [])) + ] + ) + return [(label, url) for label, url in pairs if url] + + +__all__ = [ + "CoreMetadata", + "Field", + "parse_metadata", +] diff --git a/src/python_sbom/_model/_names.py b/src/python_sbom/_model/_names.py new file mode 100644 index 0000000..81c4374 --- /dev/null +++ b/src/python_sbom/_model/_names.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import re +from typing import Final + +from packageurl import PackageURL +from packaging.utils import canonicalize_name + +#: the metadata property in which a document names its own file; validation checks such documents in full +SBOM_FILE: Final[str] = "sbom:file" +#: the metadata property in which a document names the project's repository +SBOM_REPOSITORY: Final[str] = "sbom:repository" +# the label normalization and mapping cyclonedx-py applies to Project-URL entries, so the same metadata yields the same +# reference types whichever generator produced the document, plus the well-known labels of PEP 753 it lacks; funding +# labels have no CycloneDX type and fall to "other" +_URL_LABEL_TO_REFERENCE_TYPE: Final[dict[str, str]] = { + "bugtracker": "issue-tracker", + "issuetracker": "issue-tracker", + "issues": "issue-tracker", + "issue": "issue-tracker", + "bugs": "issue-tracker", + "bugreports": "issue-tracker", + "tracker": "issue-tracker", + "home": "website", + "homepage": "website", + "download": "distribution", + "documentation": "documentation", + "docs": "documentation", + "changelog": "release-notes", + "changes": "release-notes", + "whatsnew": "release-notes", + "history": "release-notes", + "releasenotes": "release-notes", + "source": "vcs", + "sourcecode": "vcs", + "repository": "vcs", + "github": "vcs", + "chat": "chat", +} + + +def sbom_property(project: str, key: str) -> str: + return f"{project}:{key}" + + +def sbom_file_name(name: str) -> str: + return f"{property_prefix(name)}.cdx.json" + + +def property_prefix(name: str) -> str: + # the normalized project name, which prefixes the properties this package writes + return canonicalize_name(name) + + +def purl(name: str, version: str | None = None) -> str: + return PackageURL(type="pypi", name=name, version=version).to_string() + + +def pypi_url(name: str, version: str | None = None) -> str: + return f"https://pypi.org/project/{name}/{version}/" if version else f"https://pypi.org/project/{name}/" + + +def tool_ref(ref: str) -> str: + # prefixed because the same distribution can be both a build tool and a component of the artifact + return f"tool:{ref}" + + +def reference_type(label: str) -> str: + return _URL_LABEL_TO_REFERENCE_TYPE.get(re.sub(r"[^a-z]", "", label.lower()), "other") + + +__all__ = [ + "SBOM_FILE", + "SBOM_REPOSITORY", + "property_prefix", + "purl", + "pypi_url", + "reference_type", + "sbom_file_name", + "sbom_property", + "tool_ref", +] diff --git a/src/python_sbom/_model/_tools.py b/src/python_sbom/_model/_tools.py new file mode 100644 index 0000000..870d6a4 --- /dev/null +++ b/src/python_sbom/_model/_tools.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from packageurl import PackageURL + +from ._components import component_from_metadata, file_component +from ._names import purl, tool_ref + +if TYPE_CHECKING: + from ._inputs import BuildFacts + from ._types import Component, Dependency + + +def tool_components(facts: BuildFacts) -> tuple[list[Component], list[Dependency]]: + interpreter = facts.interpreter + package = PackageURL(type="generic", name=interpreter.name, version=interpreter.version).to_string() + tools: Final[list[Component]] = [ + { + "type": "platform", + "bom-ref": tool_ref(package), + "name": interpreter.name, + "version": interpreter.version, + "purl": package, + "properties": [{"name": "python:implementation", "value": interpreter.implementation}], + } + ] + refs: Final[dict[str, str]] = { + distribution.name: tool_ref(purl(distribution.name, distribution.metadata.version)) + for distribution in facts.distributions + } + dependencies: Final[list[Dependency]] = [] + for distribution in sorted(facts.distributions, key=lambda entry: entry.name): + component = component_from_metadata(distribution.metadata) + component["bom-ref"] = refs[distribution.name] + if distribution.files: + component["components"] = [file_component(component["bom-ref"], row) for row in distribution.files] + tools.append(component) + dependencies.append({ + "ref": component["bom-ref"], + "dependsOn": sorted(refs[name] for name in distribution.depends_on), + }) + return tools, dependencies + + +__all__ = [ + "tool_components", +] diff --git a/src/python_sbom/_model/_types.py b/src/python_sbom/_model/_types.py new file mode 100644 index 0000000..6c154d4 --- /dev/null +++ b/src/python_sbom/_model/_types.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from typing import NotRequired, TypedDict + +# the document model is our own: cyclonedx-python-lib models neither formulation nor compositions (its model/bom.py +# holds only commented-out stubs, tracked upstream in CycloneDX/cyclonedx-python-lib#578 and #581), and its SortedSet +# collections re-order lists whose order the document keeps on purpose + + +class OrganizationalEntity(TypedDict): + """ + An organization that supplies or makes a component. + + :ivar name: the organization's name + :ivar url: the organization's web sites + + """ + + name: str + url: list[str] + + +class Contact(TypedDict): + """ + A person who wrote or maintains a component. + + :ivar name: the person's name + :ivar email: the person's email address + + """ + + name: NotRequired[str] + email: NotRequired[str] + + +class Hash(TypedDict): + """ + A digest of a component's bytes. + + :ivar alg: the algorithm as CycloneDX names it, such as ``SHA-256`` + :ivar content: the digest as lowercase hex + + """ + + alg: str + content: str + + +class Property(TypedDict): + """ + A name and value pair attached to a component or the document. + + :ivar name: the property's name; names this package writes start with the project's prefix or ``python:`` + :ivar value: the property's value + + """ + + name: str + value: str + + +class ExternalReference(TypedDict): + """ + A link from a component to something about it. + + :ivar type: the CycloneDX reference type, such as ``vcs`` or ``website`` + :ivar url: the link + :ivar comment: what the link is, such as the Project-URL label it came from + + """ + + type: str + url: str + comment: NotRequired[str] + + +class LicenseInfo(TypedDict): + """ + One license, by SPDX id or by name. + + :ivar id: the SPDX license id + :ivar name: the license's name, when it has no SPDX id + :ivar acknowledgement: ``declared`` when the metadata names it, ``concluded`` when python-sbom resolved it + + """ + + id: NotRequired[str] + name: NotRequired[str] + acknowledgement: str + + +class License(TypedDict): + """ + One license entry of a component: a license or an SPDX expression. + + :ivar license: the license, by SPDX id or by name + :ivar expression: an SPDX license expression + :ivar acknowledgement: ``declared`` or ``concluded``, for an expression + + """ + + license: NotRequired[LicenseInfo] + expression: NotRequired[str] + acknowledgement: NotRequired[str] + + +class IdentityMethod(TypedDict): + """ + How a component's identity was established. + + :ivar technique: the CycloneDX technique, such as ``manifest-analysis`` or ``hash-comparison`` + :ivar confidence: from 0 to 1 + :ivar value: what the technique read, such as the path of a ``METADATA`` file or a digest + + """ + + technique: str + confidence: int + value: str + + +class Identity(TypedDict): + """ + The evidence for one field of a component's identity. + + :ivar field: the field it supports, such as ``purl`` or ``hash`` + :ivar confidence: from 0 to 1 + :ivar methods: how it was established + + """ + + field: str + confidence: int + methods: list[IdentityMethod] + + +class Evidence(TypedDict): + """ + Why a component is what it says it is. + + :ivar identity: the evidence per field + + """ + + identity: list[Identity] + + +#: A CycloneDX 1.6 component: a distribution, an application, the interpreter or a file, as a dictionary with the +#: keys the CycloneDX component object defines, of which python-sbom uses ``type``, ``bom-ref``, ``name``, +#: ``version``, ``purl``, ``supplier``, ``authors``, ``description``, ``licenses``, ``copyright``, ``hashes``, +#: ``externalReferences``, ``properties``, ``evidence`` and the nested ``components``. +Component = TypedDict( + "Component", + { + "type": str, + "bom-ref": str, + "name": str, + "version": NotRequired[str], + "purl": NotRequired[str], + "supplier": NotRequired[OrganizationalEntity], + "authors": NotRequired[list[Contact]], + "description": NotRequired[str], + "licenses": NotRequired[list[License]], + "copyright": NotRequired[str], + "hashes": NotRequired[list[Hash]], + "externalReferences": NotRequired[list[ExternalReference]], + "properties": NotRequired[list[Property]], + "evidence": NotRequired[Evidence], + "components": NotRequired[list["Component"]], + }, +) + + +class Dependency(TypedDict): + """ + The components one component depends on. + + :ivar ref: the ``bom-ref`` of the dependent component + :ivar dependsOn: the ``bom-ref`` values of its dependencies + + """ + + ref: str + dependsOn: NotRequired[list[str]] + + +class _Reference(TypedDict): + ref: str + + +class _Output(TypedDict): + type: str + resource: _Reference + + +class _Input(TypedDict): + environmentVars: list[Property] + + +_Workflow = TypedDict( + "_Workflow", + { + "bom-ref": str, + "uid": str, + "name": str, + "taskTypes": list[str], + "resourceReferences": list[_Reference], + "outputs": list[_Output], + "inputs": NotRequired[list[_Input]], + }, +) + + +#: How an artifact was built: one or more workflows, each with its tools, inputs and outputs. +Formula = TypedDict("Formula", {"bom-ref": str, "workflows": list[_Workflow]}) + + +class Lifecycle(TypedDict): + phase: str + + +class Tools(TypedDict): + components: list[Component] + + +class Metadata(TypedDict): + timestamp: str + lifecycles: list[Lifecycle] + tools: Tools + manufacturer: NotRequired[OrganizationalEntity] + authors: NotRequired[list[Contact]] + supplier: NotRequired[OrganizationalEntity] + component: Component + licenses: NotRequired[list[License]] + properties: NotRequired[list[Property]] + + +class Composition(TypedDict): + aggregate: str + dependencies: list[str] + + +class Body(TypedDict): + metadata: Metadata + components: list[Component] + dependencies: list[Dependency] + compositions: NotRequired[list[Composition]] + formulation: NotRequired[list[Formula]] + + +_Header = TypedDict( + "_Header", + {"$schema": str, "bomFormat": str, "specVersion": str, "serialNumber": str, "version": int}, +) + + +class Document(Body, _Header): + pass + + +__all__ = [ + "Body", + "Component", + "Composition", + "Contact", + "Dependency", + "Document", + "Evidence", + "ExternalReference", + "Formula", + "Hash", + "Identity", + "IdentityMethod", + "License", + "LicenseInfo", + "Lifecycle", + "Metadata", + "OrganizationalEntity", + "Property", + "Tools", +] diff --git a/src/python_sbom/_validate/__init__.py b/src/python_sbom/_validate/__init__.py new file mode 100644 index 0000000..1496c0a --- /dev/null +++ b/src/python_sbom/_validate/__init__.py @@ -0,0 +1 @@ +"""Checks of a document against the schema, its own references, its SPDX rendering and a license policy.""" diff --git a/src/python_sbom/_validate/_artifact.py b/src/python_sbom/_validate/_artifact.py new file mode 100644 index 0000000..556e753 --- /dev/null +++ b/src/python_sbom/_validate/_artifact.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import zipfile +from typing import TYPE_CHECKING, Final + +from python_sbom._wheel._archive import open_archive, read_member + +from ._document import MAX_COMPONENTS, validate_document + +if TYPE_CHECKING: + from pathlib import Path + +# an SBOM lists every file of its wheel at a few hundred bytes each, so even a wheel with a hundred thousand files stays +# well under this, while one archive member can make validation hold no more than this in memory +_SBOM_LIMIT: Final[int] = 64 * 1024 * 1024 + + +def validate(path: Path, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS) -> list[str]: + if not zipfile.is_zipfile(path): + return validate_document(path.read_text(encoding="utf-8"), allowed_licenses, max_components) + with open_archive(path, path.name) as archive: + found: Final[dict[str, str]] = { + name: read_member(archive, name, path.name, _SBOM_LIMIT).decode("utf-8") + for name in sorted(archive.namelist()) + if _is_sbom(name) + } + if not found: + return [f"no SBOM found in {path.name}"] + return [ + f"{name}: {problem}" + for name, raw in found.items() + for problem in validate_document(raw, allowed_licenses, max_components) + ] + + +def _is_sbom(name: str) -> bool: + # every top-level .dist-info counts, so a decoy one sorting first cannot hide the SBOM of another + match name.split("/"): + case [file]: + return file.endswith(".cdx.json") + case [dist_info, "sboms", file]: + return dist_info.endswith(".dist-info") and file.endswith(".cdx.json") + case _: + return False + + +__all__ = [ + "validate", +] diff --git a/src/python_sbom/_validate/_document.py b/src/python_sbom/_validate/_document.py new file mode 100644 index 0000000..290737d --- /dev/null +++ b/src/python_sbom/_validate/_document.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import re +from collections import Counter +from typing import TYPE_CHECKING, Final, TypeAlias + +from python_sbom._errors import MissingExtraError +from python_sbom._formats._spdx import render_spdx +from python_sbom._model._names import SBOM_FILE +from python_sbom.version import __version__ + +if TYPE_CHECKING: + from typing import TypeIs # new in 3.13; annotations here are never evaluated + + from python_sbom._model._types import Component, Document + +# a JSON value, as json.loads hands it over +_Json: TypeAlias = "str | int | float | bool | list[_Json] | dict[str, _Json] | None" +# the schema leaves these optional, yet every document python-sbom writes carries them and the checks below read them +_REQUIRED: Final[tuple[tuple[str, ...], ...]] = ( + ("metadata", "timestamp"), + ("metadata", "tools", "components"), + ("metadata", "component", "bom-ref"), + ("metadata", "component", "name"), + ("components",), + ("dependencies",), +) +_SERIAL: Final[re.Pattern[str]] = re.compile(r"urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +# the archive reader stops at the same size +_MAX_CHARACTERS: Final[int] = 16 * 1024 * 1024 +# the schema's uniqueItems compares every pair of items in an array, so a document whose components share one array +# takes about ten seconds to check at this count and four times that at twice it +MAX_COMPONENTS: Final[int] = 5_000 +# an exception's message can quote the hostile value that caused it +_MAX_TEXT: Final[int] = 200 + + +def validate_document( + raw: str, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS +) -> list[str]: + if len(raw) > _MAX_CHARACTERS: + return [f"is {len(raw)} characters long, over the {_MAX_CHARACTERS} character limit"] + # one document's problem never stops the others from being checked, so whatever the checks raise is reported + try: + return _problems(raw, allowed_licenses, max_components) + except MissingExtraError: + raise + except json.JSONDecodeError as error: + return [f"is not JSON: {error}"] + except RecursionError: + # the JSON parser and the schema validator recurse once per level, how deep depends on the Python version + return ["does not conform to the CycloneDX 1.6 schema: it nests too deeply to check"] + except Exception as error: # ruff: ignore[blind-except] # a hostile document may trip any check, see above + return [f"could not be checked, {type(error).__name__}: {str(error)[:_MAX_TEXT]}"] + + +def _problems(raw: str, allowed_licenses: frozenset[str] | None, max_components: int) -> list[str]: + # every document must conform to the CycloneDX 1.6 schema; one python-sbom wrote must also carry the urn:uuid serial + # actions/attest needs, resolvable and unique bom-refs and a valid SPDX rendering, and meet the license policy + from ._licenses import license_problems # ruff: ignore[import-outside-top-level] # parses with the optional extra + from ._schemas import schema_error, spdx_problems # ruff: ignore[import-outside-top-level] # the optional extra loads on first use + + loaded: Final = json.loads(raw) + if census := _census_problems(loaded, max_components): + return census + if error := schema_error(raw): + return [f"does not conform to the CycloneDX 1.6 schema: {error}"] + # python-sbom names its file in a metadata property, so documents other tools wrote get the schema check alone; the + # schema already guarantees the shape of what this reads + if not any( + prop.get("name", "").endswith(f":{SBOM_FILE}") for prop in loaded.get("metadata", {}).get("properties", []) + ): + return [] + missing: Final[list[str]] = [".".join(path) for path in _REQUIRED if not _has(loaded, path)] + # is_document holds once nothing is missing; asking it narrows the loaded JSON to a document + if missing or not is_document(loaded): + return [f"lacks {', '.join(missing)}, which every document python-sbom writes carries"] + # optional in the CycloneDX spec itself, but actions/attest's format sniffer requires it to recognize the document + # as CycloneDX at all, and silently rejects anything missing it as an unknown format + if not _SERIAL.fullmatch(serial := loaded.get("serialNumber", "")): + return [f"serialNumber must be a urn:uuid: RFC 4122 UUID, got {serial!r}"] + # the SPDX rendering of a document whose references are broken only repeats those problems + problems: Final[list[str]] = _reference_problems(loaded) or spdx_problems(render_spdx(loaded, __version__)) + if allowed_licenses is not None: + problems.extend(license_problems(loaded["components"], allowed_licenses)) + return problems + + +def _census_problems(document: _Json, max_components: int) -> list[str]: + # counts the components and bom-refs in one linear pass before the schema vouches for any shape; iterative, so deep + # nesting cannot exhaust the stack. Nested components count too: the schema checks every components array, and + # one array may hold them all, so only the total bounds its work + components = 0 + refs: Final[Counter[str]] = Counter() + pending: Final[list[_Json]] = [document] + while pending: + node = pending.pop() + if isinstance(node, list): + pending.extend(node) + elif isinstance(node, dict): + if isinstance(ref := node.get("bom-ref"), str): + refs[ref] += 1 + if isinstance(listed := node.get("components"), list): + components += len(listed) + pending.extend(node.values()) + if components > max_components: + problem = ( + f"lists more than {max_components} components, nested ones included;" + " pass a higher --max-components to check it" + ) + return [problem] + if duplicates := sorted(ref for ref, count in refs.items() if count > 1): + return [f"bom-ref values are not unique: {duplicates}"] + return [] + + +def _has(value: _Json, path: tuple[str, ...]) -> bool: + for key in path: + if not isinstance(value, dict) or key not in value: + return False + value = value[key] + return True + + +def is_document(value: object) -> TypeIs[Document]: + # what the SPDX rendering reads without a fallback; the schema leaves even these optional + return isinstance(value, dict) and all(_has(value, ("metadata", "component", key)) for key in ("bom-ref", "name")) + + +def _reference_problems(document: Document) -> list[str]: + root: Final[Component] = document["metadata"]["component"] + tools: Final[list[Component]] = document["metadata"]["tools"]["components"] + components: Final[list[Component]] = _walk([root, *document["components"], *tools]) + if nameless := sorted(component["name"] for component in components if "bom-ref" not in component): + return [f"components lack a bom-ref: {nameless}"] + problems: Final[list[str]] = [] + dependencies: Final[dict[str, set[str]]] = { + entry["ref"]: set(entry.get("dependsOn", [])) for entry in document["dependencies"] + } + refs: Final[set[str]] = {component["bom-ref"] for component in components} + if unknown := (set(dependencies) | set().union(*dependencies.values())) - refs: + problems.append(f"dependencies reference bom-refs that do not exist: {sorted(unknown)}") + direct: Final[set[str]] = {component["bom-ref"] for component in document["components"]} + if dependencies.get(root["bom-ref"]) != direct: + problems.append( + f"root dependsOn {sorted(dependencies.get(root['bom-ref'], []))} != components {sorted(direct)}" + ) + tool_refs: Final[set[str]] = {tool["bom-ref"] for tool in tools} + for formula in document.get("formulation", []): + for workflow in formula.get("workflows", []): + # a workflow may also name an external resource, which no tool can be + referenced = { + reference["ref"] for reference in workflow.get("resourceReferences", []) if "ref" in reference + } + if unknown := referenced - tool_refs: + problems.append(f"workflow {workflow['uid']} references unknown tools: {sorted(unknown)}") + return problems + + +def _walk(components: list[Component]) -> list[Component]: + # iterative, so a deeply nested hostile document cannot exhaust the stack + found: Final[list[Component]] = [] + pending: Final[list[Component]] = list(components) + while pending: + component = pending.pop() + found.append(component) + pending.extend(component.get("components", [])) + return found + + +__all__ = [ + "MAX_COMPONENTS", + "is_document", + "validate_document", +] diff --git a/src/python_sbom/_validate/_licenses.py b/src/python_sbom/_validate/_licenses.py new file mode 100644 index 0000000..a4b22f8 --- /dev/null +++ b/src/python_sbom/_validate/_licenses.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from license_expression import ( + AND, + ExpressionError, + LicenseSymbol, + LicenseWithExceptionSymbol, + Licensing, + get_license_index, +) + +if TYPE_CHECKING: + from license_expression import LicenseExpression + + from python_sbom._model._types import Component, License + +# the SPDX ids alone, without the aliases get_spdx_licensing() adds: those rename ids (LicenseRef-Apache becomes +# Apache-2.0, Net-SNMP becomes LicenseRef-scancode-net-snmp), so a policy would judge an id other than the one declared; +# knowing the ids still lets the parser tell "MIT ISC" apart from a single license +_LICENSING: Final[Licensing] = Licensing( + LicenseSymbol(entry["spdx_license_key"], is_exception=entry["is_exception"]) + for entry in get_license_index() + if entry.get("spdx_license_key") +) + + +def license_problems(components: list[Component], allowed: frozenset[str]) -> list[str]: + # SPDX matches license ids without regard to case + folded: Final[frozenset[str]] = frozenset(identifier.casefold() for identifier in allowed) + return [ + f"{component.get('bom-ref', component['name'])}: {problem}" + for component in _shipped(components) + if (problem := _judge(component, allowed, folded)) + ] + + +def _shipped(components: list[Component]) -> list[Component]: + # only what the artifact carries bytes for is judged: a declared requirement resolves at install time, and a library + # a bundled wheel vendors ships under that wheel's terms; iterative, so deep nesting cannot exhaust the stack + found: Final[list[Component]] = [] + pending: Final[list[Component]] = list(reversed(components)) + while pending: + component = pending.pop() + if component["type"] == "library" and ( + "hashes" in component or any(child["type"] == "file" for child in component.get("components", [])) + ): + found.append(component) + pending.extend(reversed(component.get("components", []))) + return found + + +def _judge(component: Component, allowed: frozenset[str], folded: frozenset[str]) -> str | None: + licenses: Final[list[License]] = component.get("licenses", []) + terms: Final[list[str]] = [ + entry["expression"] if "expression" in entry else entry["license"]["id"] + for entry in licenses + if "expression" in entry or "id" in entry["license"] + ] + if not terms: + if names := [entry["license"]["name"] for entry in licenses if "license" in entry]: + return f"declares license names {names} with no SPDX id" + return "declares no license" + denied: Final[list[str]] = [] + for term in terms: + try: + tree = _parse(term) + except ValueError as error: + return f"license expression {term!r} is malformed: {error}" + if not _allowed(tree, folded): + denied.append(term) + if denied: + return f"license {' AND '.join(denied)} is outside the allowed licenses {sorted(allowed)}" + return None + + +def _parse(term: str) -> LicenseExpression: + try: + tree: Final[LicenseExpression | None] = _LICENSING.parse(term) + except ExpressionError as error: + raise ValueError(str(error)) from error + except (AssertionError, IndexError) as error: + # boolean.py trips over some malformed input, such as "( OR MIT" or "()", before it reports it + msg = "it does not parse" + raise ValueError(msg) from error + if tree is None: + msg = "it is empty" + raise ValueError(msg) + return tree + + +def _allowed(tree: LicenseExpression, folded: frozenset[str]) -> bool: + # the parse tree nests as deep as the expression does, so it is walked with a stack instead of recursion; children + # come after their parent in this order, so the reversed walk judges them first + order: Final[list[LicenseExpression]] = [] + pending: Final[list[LicenseExpression]] = [tree] + while pending: + order.append(node := pending.pop()) + pending.extend(node.args) + verdicts: Final[dict[int, bool]] = {} + for node in reversed(order): + if isinstance(node, LicenseWithExceptionSymbol): + # an exception grants extra permissions on top of the license it modifies, so that license alone decides + verdicts[id(node)] = node.license_symbol.key.casefold() in folded + elif isinstance(node, LicenseSymbol): + verdicts[id(node)] = node.key.casefold() in folded + else: + # the parser builds only AND and OR around the symbols + branches = (verdicts[id(arg)] for arg in node.args) + verdicts[id(node)] = all(branches) if isinstance(node, AND) else any(branches) + return verdicts[id(tree)] + + +__all__ = [ + "license_problems", +] diff --git a/src/python_sbom/_validate/_schemas.py b/src/python_sbom/_validate/_schemas.py new file mode 100644 index 0000000..a744482 --- /dev/null +++ b/src/python_sbom/_validate/_schemas.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from python_sbom._errors import MissingExtraError + +try: + from cyclonedx.schema import SchemaVersion + from cyclonedx.validation.json import JsonStrictValidator, JsonValidationError + from spdx_tools.spdx.parser.error import SPDXParsingError + from spdx_tools.spdx.parser.jsonlikedict.json_like_dict_parser import JsonLikeDictParser + from spdx_tools.spdx.validation.document_validator import validate_full_spdx_document +except ImportError as error: + _MESSAGE: Final[str] = "validation needs the validate extra: pip install 'python-sbom[validate]'" + raise MissingExtraError(_MESSAGE) from error + +if TYPE_CHECKING: + from jsonschema.exceptions import ValidationError + + from python_sbom._formats._spdx import Spdx + +# loading the schema takes about a second, so the validator is built once +_VALIDATOR: Final[JsonStrictValidator] = JsonStrictValidator(SchemaVersion.V1_6) +# the message quotes the offending value, which for a document root runs to megabytes +_MAX_TEXT: Final[int] = 200 + + +def schema_error(raw: str) -> str | None: + error: Final = _VALIDATOR.validate_str(raw, all_errors=False) + # all_errors=False returns one error or None, but under a runtime check type checkers do not follow + if not isinstance(error, JsonValidationError): + return None + detail: Final[ValidationError] = error.data + return f"{detail.message[:_MAX_TEXT]} at {detail.json_path[:_MAX_TEXT]}" + + +def spdx_problems(spdx: Spdx) -> list[str]: + try: + parsed: Final = JsonLikeDictParser().parse(dict(spdx)) + except SPDXParsingError as error: + return [f"SPDX rendering does not parse: {message}" for message in error.get_messages()] + return [f"SPDX rendering invalid: {message.validation_message}" for message in validate_full_spdx_document(parsed)] + + +__all__ = [ + "schema_error", + "spdx_problems", +] diff --git a/src/python_sbom/_wheel/__init__.py b/src/python_sbom/_wheel/__init__.py new file mode 100644 index 0000000..094cffe --- /dev/null +++ b/src/python_sbom/_wheel/__init__.py @@ -0,0 +1 @@ +"""Wheel archives: their RECORD, their metadata files, and writing an SBOM into a built one.""" diff --git a/src/python_sbom/_wheel/_archive.py b/src/python_sbom/_wheel/_archive.py new file mode 100644 index 0000000..791b4d8 --- /dev/null +++ b/src/python_sbom/_wheel/_archive.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import csv +import hashlib +import os +import shutil +import stat +import tempfile +import time +import zipfile +import zlib +from collections import Counter +from contextlib import contextmanager +from io import BytesIO, StringIO +from pathlib import Path +from typing import IO, TYPE_CHECKING, Final + +import zipremove +from installer.records import Hash, RecordEntry +from installer.sources import WheelFile +from installer.utils import copyfileobj_with_hashing + +from python_sbom._errors import WheelError +from python_sbom._model._metadata import CoreMetadata, parse_metadata + +if TYPE_CHECKING: + from collections.abc import Generator + +# metadata files beyond this size are refused, so a hostile wheel cannot exhaust memory +_METADATA_LIMIT: Final[int] = 16 * 1024 * 1024 +_CHUNK: Final[int] = 1024 * 1024 + + +def write_sbom(wheel: Path, name: str, content: bytes, epoch: int) -> str: + with open_archive(wheel, str(wheel)) as archive: + _, dist_info = _wheel_source(archive, str(wheel)) + record: Final[str] = f"{dist_info}/RECORD" + if missing := sorted({f"{dist_info}/METADATA", record} - set(archive.namelist())): + msg = f"{wheel} has no {', '.join(missing)}" + raise WheelError(msg) + record_info: Final[zipfile.ZipInfo] = archive.getinfo(record) + record_data: Final[bytes] = archive.read(record_info) + sbom: Final[str] = f"{dist_info}/sboms/{name}" + # zip timestamps start at 1980 + entry = zipfile.ZipInfo(sbom, time.gmtime(max(epoch, 315532800))[:6]) + entry.compress_type = zipfile.ZIP_DEFLATED + entry.create_system = 3 # Unix, whichever system writes the entry, so the bytes match across operating systems + entry.external_attr = (stat.S_IFREG | 0o644) << 16 + rewritten: Final[bytes] = _rewritten_record(record_data, record, sbom, content) + # a name no "dist/*.whl" glob matches, in the wheel's directory so the final rename stays on one file system + descriptor, temporary = tempfile.mkstemp(dir=wheel.parent, prefix=".python-sbom-", suffix=".tmp") + os.close(descriptor) + try: # ruff: ignore[too-many-statements-in-try-clause] # whichever step fails, the temporary file goes + shutil.copyfile(wheel, temporary) + # the other members keep their bytes as stored, still compressed, so only the SBOM and RECORD get compressed + # here; both go last, RECORD after the SBOM + with zipremove.ZipFile(temporary, "a") as archive: + stale = [info for info in archive.infolist() if info.filename in {record, sbom}] + archive.repack([archive.remove(info) for info in stale]) # ty: ignore[unresolved-attribute] # zipremove adds both + archive.writestr(entry, content) + archive.writestr(record_info, rewritten) + Path(temporary).chmod(stat.S_IMODE(wheel.stat().st_mode)) + Path(temporary).replace(wheel) + except BaseException: + Path(temporary).unlink(missing_ok=True) + raise + return sbom + + +@contextmanager +def open_archive(file: Path | IO[bytes], where: str) -> Generator[zipfile.ZipFile, None, None]: + try: + with zipfile.ZipFile(file) as archive: + # installers extract the first of two same-named members and zipfile reads the last, and a file system that + # ignores case merges names that differ only by case, so such names would hide what gets installed; + # installer's WheelFile accepts names that differ only by case + if clashes := sorted( + name for name, count in Counter(name.casefold() for name in archive.namelist()).items() if count > 1 + ): + msg = f"{where} holds more than one member named {', '.join(clashes)}, ignoring case" + raise WheelError(msg) + yield archive + # zipfile raises these as late as a member's last read, inside installer's checks too, which let them through + except (zipfile.BadZipFile, zlib.error) as error: + msg = f"{where} is not a valid zip archive: {error}" + raise WheelError(msg) from error + + +def _wheel_source(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, str]: + # installer refuses a wheel whose file name does not parse, or that holds other than one top-level .dist-info, + # since a second one is a decoy that could decide whose metadata counts + try: + wheel = WheelFile(archive) + dist_info = wheel.dist_info_dir + except ValueError as error: + msg = f"{where} is not a wheel: {error}" + raise WheelError(msg) from error + return wheel, dist_info + + +def _rewritten_record(record: bytes, name: str, sbom: str, content: bytes) -> bytes: + lines: Final[list[str]] = [ + line for line in record.decode("utf-8").splitlines(keepends=True) if not line.startswith(f"{sbom},") + ] + newline: Final[str] = "\r\n" if lines and lines[0].endswith("\r\n") else "\n" + # the row of RECORD itself stays last + at: Final[int] = next((index for index, line in enumerate(lines) if line.startswith(f"{name},")), len(lines)) + # installer's helper hashes as it copies; the copy is thrown away + digest, size = copyfileobj_with_hashing(BytesIO(content), BytesIO(), "sha256") + row: Final[StringIO] = StringIO() + csv.writer(row, lineterminator=newline).writerow(RecordEntry(sbom, Hash("sha256", digest), size).to_row()) + lines.insert(at, row.getvalue()) + return "".join(lines).encode() + + +def read_wheel_metadata(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, CoreMetadata]: + wheel, dist_info = _wheel_source(archive, where) + # read capped, as installer's read_dist_info decompresses the member whole + return wheel, parse_metadata(read_member(archive, f"{dist_info}/METADATA", where).decode("utf-8")) + + +def read_member(archive: zipfile.ZipFile, name: str, where: str, limit: int = _METADATA_LIMIT) -> bytes: + try: + info = archive.getinfo(name) + except KeyError as error: + msg = f"{where} has no {name}" + raise WheelError(msg) from error + # counted as it decompresses, since the size the archive states may lie and one read decompresses it all at once + content: Final[bytearray] = bytearray() + with archive.open(info) as member: + while chunk := member.read(_CHUNK): + content.extend(chunk) + if len(content) > limit: + msg = f"{where}: {name} is over the {limit} byte limit" + raise WheelError(msg) + return bytes(content) + + +def member_digest(archive: zipfile.ZipFile, name: str | zipfile.ZipInfo, algorithm: str) -> str: + digest: Final = hashlib.new(algorithm) + with archive.open(name) as member: + while chunk := member.read(_CHUNK): + digest.update(chunk) + return digest.hexdigest() + + +__all__ = [ + "member_digest", + "open_archive", + "read_member", + "read_wheel_metadata", + "write_sbom", +] diff --git a/src/python_sbom/_wheel/_record.py b/src/python_sbom/_wheel/_record.py new file mode 100644 index 0000000..a7af8da --- /dev/null +++ b/src/python_sbom/_wheel/_record.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import base64 +import binascii +from typing import Final + +from python_sbom._errors import WheelError + +# the algorithms a wheel RECORD may name, as CycloneDX spells them +_ALGORITHMS: Final[dict[str, str]] = { + "md5": "MD5", + "sha1": "SHA-1", + "sha256": "SHA-256", + "sha384": "SHA-384", + "sha512": "SHA-512", +} + + +def decode_digest(digest: str, where: str) -> tuple[str, str]: + # installer checks RECORD digests as urlsafe base64 under any hashlib name, while CycloneDX takes hex under its own + # names for a few algorithms + name, _, encoded = digest.partition("=") + if (algorithm := _ALGORITHMS.get(name)) is None: + msg = f"{where}: unknown hash algorithm {name!r}" + raise WheelError(msg) + try: + raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + except binascii.Error as error: + msg = f"{where}: digest {encoded!r} is not urlsafe base64" + raise WheelError(msg) from error + return algorithm, raw.hex() + + +__all__ = [ + "decode_digest", +] diff --git a/src/python_sbom/hatch/__init__.py b/src/python_sbom/hatch/__init__.py new file mode 100644 index 0000000..4e6ce53 --- /dev/null +++ b/src/python_sbom/hatch/__init__.py @@ -0,0 +1,9 @@ +"""The hatchling build hook, which writes the SBOM into every wheel hatchling builds.""" + +from __future__ import annotations + +from ._hook import SbomBuildHook + +__all__ = [ + "SbomBuildHook", +] diff --git a/src/python_sbom/hatch/_hook.py b/src/python_sbom/hatch/_hook.py new file mode 100644 index 0000000..365b83a --- /dev/null +++ b/src/python_sbom/hatch/_hook.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import tempfile +from functools import cached_property +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, Final + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from hatchling.builders.utils import get_reproducible_timestamp +from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT + +from python_sbom._collect._content import load_content_provider, provider_script +from python_sbom._collect._facts import REVISION_FILE, collect_facts, default_revision +from python_sbom._collect._settings import settings_from_table +from python_sbom._collect._source import wheel_request +from python_sbom._errors import SettingsError +from python_sbom._generate._embed import embed_into + +if TYPE_CHECKING: + from python_sbom._model._content import ContentProvider + from python_sbom._model._inputs import Settings + +# options of every hatchling hook table, which hatchling itself reads +_HATCHLING_OPTIONS: Final[frozenset[str]] = frozenset({ + "dependencies", + "enable-by-default", + "path", + "require-runtime-dependencies", + "require-runtime-features", +}) + + +class SbomBuildHook(BuildHookInterface): + """ + Hatchling build hook that writes a CycloneDX SBOM into the wheel's ``.dist-info/sboms/``, as PEP 770 describes. + + It runs once hatchling has written the wheel, so the SBOM describes the wheel's final metadata and file name + whatever other hooks changed. Options come from ``[tool.python-sbom]``, the one place ``python-sbom embed`` and + ``python-sbom archive`` read them too. For an sdist it records the source revision, so a wheel built from that sdist + still names its commit. + + :cvar PLUGIN_NAME: the hook's name in ``[tool.hatch.build.hooks]`` + + """ + + PLUGIN_NAME = "python-sbom" + + def initialize( + self, + version: str, # ruff: ignore[unused-method-argument] # hatchling's signature + build_data: dict[str, Any], # simplint: ignore[SMP602] # hatchling's signature + ) -> None: + """ + Record the source revision in an sdist. + + :param version: hatchling's build variant + :param build_data: hatchling's build data, which receives the revision file + + """ + # a dirty checkout has no commit that reproduces the sdist, so a wheel built from it names none + if self.target_name != "sdist" or (revision := default_revision(Path(self.root))) is None or revision.dirty: + return + # outside the project, so the build leaves nothing behind in it; bytes, so the sdist holds the same LF line on + # every OS + written = Path(tempfile.mkdtemp()) / REVISION_FILE + written.write_bytes(f"node: {revision.commit}\n".encode()) + build_data["force_include"][str(written)] = REVISION_FILE + + def finalize( + self, + version: str, + build_data: dict[str, Any], # ruff: ignore[unused-method-argument] # simplint: ignore[SMP602] # hatchling's signature + artifact_path: str, + ) -> None: + """ + Write the SBOM into the wheel hatchling built. + + :param version: hatchling's build variant; an editable wheel gets no SBOM, since it carries no code + :param build_data: hatchling's build data + :param artifact_path: the built wheel + + """ + if self.target_name != "wheel" or version == "editable": + return + root = Path(self.root) + content = self.content_provider() + # a custom hook script shapes the document, as a provider defined in the project does, so the SBOM lists both + # among the build tools + own = [PurePosixPath(self.config.get("path", DEFAULT_BUILD_SCRIPT))] if self.PLUGIN_NAME == "custom" else [] + scripts = tuple(dict.fromkeys([*own, *filter(None, [provider_script(content, root)])])) + # hatchling's own fixed time for a reproducible build when SOURCE_DATE_EPOCH is unset, so the SBOM agrees with + # the zip entries hatchling wrote + facts = collect_facts( + root, + default_revision, + default_epoch=get_reproducible_timestamp() if self.build_config.reproducible else None, + ) + wheel = Path(artifact_path) + embed_into(wheel, wheel_request(wheel, root, self._settings, scripts), content, facts) + + def content_provider(self) -> ContentProvider: + """ + Return what the project adds to its SBOM. + + Override it in a hatchling custom build hook to build the provider with what the hook knows; the + ``content-provider`` option is then ignored. + + :returns: the ``content-provider`` option's provider, or one that adds nothing + + """ + return load_content_provider(self._settings.content_provider, Path(self.root)) + + @cached_property + def _settings(self) -> Settings: + # hatchling has parsed pyproject.toml already; the hook's own table holds only hatchling's options, so every + # entry point reads the same [tool.python-sbom] + if unknown := sorted(self.config.keys() - _HATCHLING_OPTIONS): + msg = ( + f"the {self.PLUGIN_NAME} hook's table holds {', '.join(unknown)}; move python-sbom's options to" + " [tool.python-sbom], where python-sbom embed and python-sbom archive read them too" + ) + raise SettingsError(msg) + return settings_from_table(self.metadata.config.get("tool", {}).get("python-sbom", {}), "[tool.python-sbom]") + + +__all__ = [ + "SbomBuildHook", +] diff --git a/src/python_sbom/hatch/hooks.py b/src/python_sbom/hatch/hooks.py new file mode 100644 index 0000000..ed86134 --- /dev/null +++ b/src/python_sbom/hatch/hooks.py @@ -0,0 +1,18 @@ +"""Register the build hook with hatchling.""" + +from __future__ import annotations + +from hatchling.plugin import hookimpl + +from ._hook import SbomBuildHook + + +@hookimpl +def hatch_register_build_hook() -> type[SbomBuildHook]: + """ + Return the build hook hatchling loads for ``[tool.hatch.build.hooks.python-sbom]``. + + :returns: the build hook class + + """ + return SbomBuildHook diff --git a/src/python_sbom/py.typed b/src/python_sbom/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/_cli/test_commands.py b/tests/_cli/test_commands.py new file mode 100644 index 0000000..cc1a32a --- /dev/null +++ b/tests/_cli/test_commands.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import json +import runpy +import subprocess +import sys +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._cli._main import main +from python_sbom._formats._json import dumps +from python_sbom.version import __version__ + +if TYPE_CHECKING: + from conftest import Describe, Install, WriteWheel + + from python_sbom._model._types import Document + +_SBOM: Final[str] = "native-1.0.dist-info/sboms/native.cdx.json" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_text("[tool.python-sbom]\ndefault-branch = 'trunk'\n", encoding="utf-8") + return root + + +@pytest.fixture +def wheel(tmp_path: Path, write_wheel: WriteWheel) -> Path: + return write_wheel( + tmp_path / "dist" / "native-1.0-py3-none-any.whl", + {"native/__init__.py": b"", "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n"}, + ) + + +def test_embed( + project: Path, wheel: Path, tmp_path: Path, install: Install, capsys: pytest.CaptureFixture[str] +) -> None: + commit = "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff" + install(site := tmp_path / "site", "Name: meson-python\nVersion: 0.18\n", {}) + main(["embed", str(wheel), "--root", str(project), "--commit", commit, "--build-env", str(site)]) + document = embedded(wheel) + assert capsys.readouterr().out == f"wrote {_SBOM} into {wheel.name}\n" + assert {"name": "native:vcs-commit", "value": commit} in document["metadata"]["component"]["properties"] + assert [tool["name"] for tool in document["metadata"]["tools"]["components"][1:]] == ["meson-python"] + + +def embedded(wheel: Path) -> Document: + return json.loads(zipfile.ZipFile(wheel).read(_SBOM)) + + +def test_embed_defaults_to_current_directory(project: Path, wheel: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(project) + monkeypatch.setenv("PYTHON_SBOM_COMMIT", "cafecafecafecafecafecafecafecafecafecafe") + main(["embed", str(wheel), "--build-env", str(project)]) + assert {"name": "native:vcs-commit", "value": "cafecafecafecafecafecafecafecafecafecafe"} in embedded(wheel)[ + "metadata" + ]["component"]["properties"] + + +@pytest.mark.parametrize( + ("option", "table"), + [ + pytest.param(["--content-provider", "MODULE:Native"], "", id="option"), + pytest.param([], "content-provider = 'MODULE:Native'\n", id="setting"), + ], +) +def test_embed_with_content_provider(tmp_path: Path, project: Path, wheel: Path, option: list[str], table: str) -> None: + # a module name unique to the test, so the import cache never hands over another test's module + provider = """\ +from python_sbom import ContentProvider, Contributions + + +class Native(ContentProvider): + def contributions(self, context): + return Contributions( + root_properties=[{"name": "extra:by", "value": "cli"}] + ) +""" + (project / f"{tmp_path.name}.py").write_text(provider, encoding="utf-8") + (project / "pyproject.toml").write_text(f"[tool.python-sbom]\n{table.replace('MODULE', tmp_path.name)}") + main([ + "embed", + str(wheel), + "--root", + str(project), + "--build-env", + str(project), + *(argument.replace("MODULE", tmp_path.name) for argument in option), + ]) + document = embedded(wheel) + assert {"name": "extra:by", "value": "cli"} in document["metadata"]["component"]["properties"] + assert document["metadata"]["tools"]["components"][0]["name"] == f"{tmp_path.name}.py" + + +def test_error_without_traceback(tmp_path: Path, wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exit_info: + main(["embed", str(wheel), "--root", str(tmp_path)]) + assert exit_info.value.code == 1 + assert capsys.readouterr().err == f"python-sbom: {tmp_path} has no pyproject.toml to read [tool.python-sbom] from\n" + + +def test_spdx(tmp_path: Path, describe: Describe) -> None: + (source := tmp_path / "example.cdx.json").write_bytes(dumps(describe())) + main(["spdx", str(source), str(target := tmp_path / "example.spdx.json")]) + assert json.loads(target.read_text(encoding="utf-8"))["spdxVersion"] == "SPDX-2.3" + + +def test_validate_directory(project: Path, wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + main(["embed", str(wheel), "--root", str(project), "--build-env", str(project)]) + capsys.readouterr() + main(["validate", str(wheel.parent), "--allow-license", "MIT"]) + assert capsys.readouterr().out == f"SBOM in {wheel.name} is valid\n" + + +def test_validate_reports_problems(wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["validate", str(wheel)]) + assert capsys.readouterr().err == f"SBOM invalid: {wheel.name}: no SBOM found in {wheel.name}\n" + + +@pytest.fixture +def crowded_wheel(tmp_path: Path, write_wheel: WriteWheel) -> Path: + # 100 groups of 50 parts, 5,100 components in all, yet no array long enough to slow the schema check + groups = [ + { + "type": "library", + "name": f"g{group}", + "components": [{"type": "library", "name": f"p{part}"} for part in range(50)], + } + for group in range(100) + ] + sbom = json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": groups}).encode() + return write_wheel(tmp_path / "native-1.0-py3-none-any.whl", {"native-1.0.dist-info/METADATA": b"", _SBOM: sbom}) + + +def test_validate_caps_components(crowded_wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["validate", str(crowded_wheel)]) + assert capsys.readouterr().err == ( + f"SBOM invalid: {crowded_wheel.name}: {_SBOM}: lists more than 5000 components, nested ones included;" + " pass a higher --max-components to check it\n" + ) + + +def test_validate_max_components_raises_the_cap(crowded_wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + main(["validate", str(crowded_wheel), "--max-components", "5100"]) + assert capsys.readouterr().out == f"SBOM in {crowded_wheel.name} is valid\n" + + +def test_validate_without_wheels(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["validate", str(tmp_path)]) + assert capsys.readouterr().err == f"no wheel found in {tmp_path}\n" + + +def test_module_entry_point(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setattr(sys, "argv", ["python-sbom", "--help"]) + with pytest.raises(SystemExit): + runpy.run_module("python_sbom", run_name="__main__") + assert capsys.readouterr().out.startswith("usage: python-sbom ") + + +def test_console_script() -> None: + script = Path(sys.executable).parent / "python-sbom" + assert subprocess.check_output([script, "--help"], text=True).startswith("usage: python-sbom ") + + +@pytest.fixture +def zipapp(tmp_path: Path) -> Path: + with zipfile.ZipFile(target := tmp_path / "app.pyz", "w") as handle: + handle.writestr("__main__.py", "import app\n") + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\nLicense-Expression: MIT\n") + handle.writestr("app/__init__.py", "") + return target + + +def test_archive_writes_sbom_next_to_archive(project: Path, zipapp: Path, capsys: pytest.CaptureFixture[str]) -> None: + main(["archive", str(zipapp), "--root", str(project), "--build-env", str(project)]) + assert ( + capsys.readouterr().out, + json.loads(zipapp.with_name("app.pyz.cdx.json").read_text())["metadata"]["component"]["name"], + ) == ( + f"wrote {zipapp.with_name('app.pyz.cdx.json')}\n", + "app.pyz", + ) + + +def test_archive_embeds_sbom(project: Path, zipapp: Path, tmp_path: Path) -> None: + (project / "build_app.py").write_text("", encoding="utf-8") + main([ + "archive", + str(zipapp), + "--root", + str(project), + "--build-env", + str(project), + "--build-script", + "build_app.py", + "--output", + str(tmp_path / "out.json"), + "--embed", + ]) + entry = zipfile.ZipFile(zipapp).getinfo("app.pyz.cdx.json") + assert (entry.create_system, entry.external_attr, zipfile.ZipFile(zipapp).read(entry)) == ( + 3, + 0o644 << 16, + (tmp_path / "out.json").read_bytes(), + ) + + +def test_archive_refuses_second_embed(project: Path, zipapp: Path, capsys: pytest.CaptureFixture[str]) -> None: + arguments = ["archive", str(zipapp), "--root", str(project), "--build-env", str(project), "--embed"] + main(arguments) + capsys.readouterr() + with pytest.raises(SystemExit): + main(arguments) + assert capsys.readouterr().err == "python-sbom: app.pyz already holds app.pyz.cdx.json\n" + + +def test_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["--version"]) + assert capsys.readouterr().out == f"python-sbom {__version__}\n" + + +def test_spdx_refuses_non_document(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + (source := tmp_path / "other.json").write_text("[]", encoding="utf-8") + with pytest.raises(SystemExit): + main(["spdx", str(source), str(tmp_path / "out.json")]) + assert capsys.readouterr().err == f"python-sbom: {source} is not a CycloneDX document python-sbom can render\n" + + +def test_spdx_refuses_malformed_document(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + document = {"metadata": {"component": {"bom-ref": "app", "name": "app"}}, "components": [{"name": "x"}]} + (source := tmp_path / "broken.json").write_text(json.dumps(document), encoding="utf-8") + with pytest.raises(SystemExit): + main(["spdx", str(source), str(tmp_path / "out.json")]) + assert capsys.readouterr().err.startswith( + f"python-sbom: {source} is not a CycloneDX document python-sbom can render: " + ) diff --git a/tests/_collect/test_content.py b/tests/_collect/test_content.py new file mode 100644 index 0000000..73e7da6 --- /dev/null +++ b/tests/_collect/test_content.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from pathlib import Path, PurePosixPath + +import pytest + +from python_sbom import ContentProvider +from python_sbom._collect._content import load_content_provider, provider_script +from python_sbom._errors import SettingsError + + +@pytest.fixture +def module(tmp_path: Path) -> str: + # a name unique to the test, so the import cache never hands over another test's module + (tmp_path / "tools").mkdir() + provider = """\ +from python_sbom import ContentProvider + + +class Content(ContentProvider): + pass + + +instance = Content() +other = object() + + +class Holder: + nested = instance +""" + (tmp_path / "tools" / f"{tmp_path.name}.py").write_text(provider, encoding="utf-8") + return f"tools.{tmp_path.name}" + + +@pytest.mark.parametrize( + "attribute", + [ + pytest.param("Content", id="class"), + pytest.param("instance", id="instance"), + pytest.param("Holder.nested", id="dotted-attribute"), + ], +) +def test_load_from_project(tmp_path: Path, module: str, attribute: str) -> None: + provider = load_content_provider(f"{module}:{attribute}", tmp_path) + assert (type(provider).__name__, provider_script(provider, tmp_path)) == ( + "Content", + PurePosixPath(f"tools/{tmp_path.name}.py"), + ) + + +def test_provider_outside_project_is_no_tool(tmp_path: Path) -> None: + provider = load_content_provider("python_sbom:ContentProvider", tmp_path) + assert (type(provider), provider_script(provider, tmp_path)) == (ContentProvider, None) + + +def test_provider_class_outside_project_is_no_tool(tmp_path: Path) -> None: + class Local(ContentProvider): + pass + + assert provider_script(Local(), tmp_path) is None + + +@pytest.mark.parametrize( + ("spec", "message"), + [ + pytest.param("tools", "must be `module:attribute`", id="no-attribute"), + pytest.param(":Content", "must be `module:attribute`", id="no-module"), + pytest.param("tools:Content:x", "does not load from .*: invalid format", id="two-colons"), + pytest.param("not_a_module_anywhere:Content", "does not load from .*: No module named", id="import-fails"), + pytest.param("MODULE:other", "is not a ContentProvider subclass or instance", id="not-a-provider"), + pytest.param("MODULE:missing", "does not load from .*: .* has no attribute 'missing'", id="missing-attribute"), + ], +) +def test_load_refuses(tmp_path: Path, module: str, spec: str, message: str) -> None: + with pytest.raises(SettingsError, match=message): + load_content_provider(spec.replace("MODULE", module), tmp_path) + + +def test_provider_without_source_is_no_tool(tmp_path: Path) -> None: + provider = type("Generated", (ContentProvider,), {"__module__": "not_a_loaded_module"})() + assert provider_script(provider, tmp_path) is None diff --git a/tests/_collect/test_facts.py b/tests/_collect/test_facts.py new file mode 100644 index 0000000..a4b29e2 --- /dev/null +++ b/tests/_collect/test_facts.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import hashlib +import platform +import re +import sys +import time +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._collect._facts import REVISION_FILE, collect_facts, default_revision, git_revision +from python_sbom._errors import SettingsError +from python_sbom._model._inputs import Interpreter, RecordRow, Revision + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import Git, Install + + from python_sbom._model._inputs import BuildFacts + + +def test_epoch_from_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + facts = collect_facts(tmp_path, no_revision, path=[], default_epoch=0) + assert facts.epoch == 1_700_000_000 + assert facts.epoch_from_environment + + +def no_revision(_root: Path, /) -> None: + return None + + +def test_epoch_default(tmp_path: Path) -> None: + facts = collect_facts(tmp_path, no_revision, path=[], default_epoch=42) + assert (facts.epoch, facts.epoch_from_environment) == (42, False) + + +def test_empty_epoch_is_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "") + facts = collect_facts(tmp_path, no_revision, path=[], default_epoch=42) + assert (facts.epoch, facts.epoch_from_environment) == (42, False) + + +def test_epoch_without_default_is_now(tmp_path: Path) -> None: + before = int(time.time()) + assert before <= collect_facts(tmp_path, no_revision, path=[]).epoch <= time.time() + + +def test_invalid_epoch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "yesterday") + with pytest.raises(SettingsError, match="SOURCE_DATE_EPOCH must be a Unix timestamp, got 'yesterday'"): + collect_facts(tmp_path, no_revision, path=[]) + + +def test_interpreter(tmp_path: Path) -> None: + assert collect_facts(tmp_path, no_revision, path=[], default_epoch=0).interpreter == Interpreter( + name=sys.implementation.name, + version=platform.python_version(), + implementation=platform.python_implementation(), + ) + + +def test_revision_reads_root(tmp_path: Path) -> None: + def reader(root: Path, /) -> Revision: + return Revision(commit=root.name, dirty=False) + + assert collect_facts(tmp_path, reader, path=[], default_epoch=0).revision == Revision( + commit=tmp_path.name, dirty=False + ) + + +def test_default_path_is_this_interpreter(tmp_path: Path) -> None: + names = { + distribution.metadata.name + for distribution in collect_facts(tmp_path, no_revision, default_epoch=0).distributions + } + assert "pytest" in names + + +def test_pure_distribution_files(tmp_path: Path, install: Install) -> None: + # nested, so the console script, two levels up as installers write it, stays inside the test's directory + install( + site := tmp_path / "lib" / "site-packages", + "Name: alpha\nVersion: 1.0\n", + { + "alpha/__init__.py": b"x = 1\n", + "../../bin/alpha": b"#!/usr/bin/python\n", + "alpha-1.0.dist-info/INSTALLER": b"uv\n", + "alpha-1.0.dist-info/REQUESTED": b"", + "alpha-1.0.dist-info/direct_url.json": b'{"url": "file:///tmp/alpha.whl", "archive_info": {}}', + "alpha-1.0.dist-info/uv_cache.json": b'{"timestamp": 1}', + }, + ) + (distribution,) = facts_from(site).distributions + assert distribution.files == [ + RecordRow( + path="alpha/__init__.py", algorithm="SHA-256", digest=hashlib.sha256(b"x = 1\n").hexdigest(), size="6" + ), + RecordRow( + path="alpha-1.0.dist-info/METADATA", + algorithm="SHA-256", + digest=hashlib.sha256(b"Name: alpha\nVersion: 1.0\n").hexdigest(), + size="25", + ), + RecordRow( + path="alpha-1.0.dist-info/WHEEL", + algorithm="SHA-256", + digest=hashlib.sha256(b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\n").hexdigest(), + size="41", + ), + ] + + +def facts_from(site: Path) -> BuildFacts: + return collect_facts(site, no_revision, path=[str(site)], default_epoch=0) + + +def test_platform_distribution_lists_no_files(tmp_path: Path, install: Install) -> None: + install(tmp_path, "Name: native\nVersion: 1.0\n", {"native.so": b"\x7fELF"}, purelib=False) + assert facts_from(tmp_path).distributions[0].files == [] + + +def test_editable_distribution_lists_no_files(tmp_path: Path, install: Install) -> None: + editable = '{"url": "file:///checkout", "dir_info": {"editable": true}}' + install(tmp_path, "Name: local\nVersion: 1.0\n", {"local.pth": b"/checkout/src\n"}, direct_url=editable) + assert facts_from(tmp_path).distributions[0].files == [] + + +def test_directory_install_not_editable_lists_files(tmp_path: Path, install: Install) -> None: + install(tmp_path, "Name: local\nVersion: 1.0\n", {}, direct_url='{"url": "file:///checkout", "dir_info": {}}') + assert [row.path for row in facts_from(tmp_path).distributions[0].files] == [ + "local-1.0.dist-info/METADATA", + "local-1.0.dist-info/WHEEL", + ] + + +@pytest.mark.parametrize( + ("direct_url", "message"), + [ + pytest.param("{", "Expecting property name", id="not-json"), + pytest.param('{"dir_info": {"editable": true}}', "Missing required value in 'url'", id="no-url"), + pytest.param( + '{"url": "file:///checkout", "dir_info": {"editable": "yes"}}', + r"Unexpected type str \(expected bool\) in 'dir_info.editable'", + id="editable-not-bool", + ), + ], +) +def test_malformed_direct_url_reads_as_not_editable( + tmp_path: Path, install: Install, direct_url: str, message: str +) -> None: + install(tmp_path, "Name: local\nVersion: 1.0\n", {}, direct_url=direct_url) + with pytest.warns(UserWarning, match=f"read the direct_url.json of local as not editable: {message}"): + files = facts_from(tmp_path).distributions[0].files + assert len(files) == 2 + + +def test_first_distribution_on_path_wins(tmp_path: Path, install: Install) -> None: + install(first := tmp_path / "first", "Name: alpha\nVersion: 1.0\n", {}) + install(second := tmp_path / "second", "Name: Alpha\nVersion: 2.0\n", {}) + (distribution,) = collect_facts( + tmp_path, no_revision, path=[str(first), str(second)], default_epoch=0 + ).distributions + assert distribution.metadata.version == "1.0" + + +def test_depends_on_installed_requirements_that_apply(tmp_path: Path, install: Install) -> None: + install( + tmp_path, + "Name: alpha\nVersion: 1.0\nRequires-Dist: Beta>=1\nRequires-Dist: gamma; python_version < '3'\n" + "Requires-Dist: delta; extra == 'cli'\nRequires-Dist: missing\n", + {}, + ) + for name in ("beta", "gamma", "delta"): + install(tmp_path, f"Name: {name}\nVersion: 1.0\n", {}) + by_name = {distribution.metadata.name: distribution for distribution in facts_from(tmp_path).distributions} + assert by_name["alpha"].depends_on == ["beta"] + + +def test_egg_info_distribution(tmp_path: Path) -> None: + (egg_info := tmp_path / "legacy-1.0-py3.14.egg-info").mkdir() + (egg_info / "PKG-INFO").write_text("Metadata-Version: 2.1\nName: Legacy\nVersion: 1.0\n", encoding="utf-8") + (distribution,) = facts_from(tmp_path).distributions + assert (distribution.name, distribution.metadata.version, distribution.files) == ("legacy", "1.0", []) + + +def test_recorded_file_missing_on_disk_is_listed(tmp_path: Path, install: Install) -> None: + # the install recorded it, and Python 3.11 lists such a file too, where later versions' Distribution.files drop it + install(tmp_path, "Name: alpha\nVersion: 1.0\n", {"alpha.py": b"x = 1\n"}) + (tmp_path / "alpha.py").unlink() + assert [row.path for row in facts_from(tmp_path).distributions[0].files] == [ + "alpha.py", + "alpha-1.0.dist-info/METADATA", + "alpha-1.0.dist-info/WHEEL", + ] + + +@pytest.mark.parametrize( + ("row", "issue"), + [ + pytest.param("alpha.py,sha256=x", "expected 3 elements, got 2", id="two-columns"), + pytest.param("alpha.py,sha256=x,big", "`size` cannot be non-integer", id="size-not-integer"), + pytest.param("alpha.py,nohash,1", "`hash` does not follow the required format", id="hash-without-name"), + ], +) +def test_malformed_record_lists_no_files(tmp_path: Path, install: Install, row: str, issue: str) -> None: + install(tmp_path, "Name: alpha\nVersion: 1.0\n", {}) + (tmp_path / "alpha-1.0.dist-info" / "RECORD").write_bytes(f"{row}\n".encode()) + with pytest.warns(UserWarning, match=f"listed no files of alpha, its RECORD is malformed: .*{re.escape(issue)}"): + assert facts_from(tmp_path).distributions[0].files == [] + + +@pytest.mark.parametrize( + "files", + [ + pytest.param({}, id="without-metadata"), + pytest.param({"METADATA": "Name: broken\n"}, id="without-version"), + ], +) +def test_distribution_without_name_and_version_left_out( + tmp_path: Path, install: Install, files: dict[str, str] +) -> None: + install(tmp_path, "Name: ok\nVersion: 1.0\n", {}) + (broken := tmp_path / "broken-1.0.dist-info").mkdir() + for name, text in files.items(): + (broken / name).write_text(text, encoding="utf-8") + assert [distribution.name for distribution in facts_from(tmp_path).distributions] == ["ok"] + + +def test_invalid_requirement_left_out(tmp_path: Path, install: Install) -> None: + install(tmp_path, "Name: old\nVersion: 1.0\nRequires-Dist: pytz (>dev)\nRequires-Dist: pytz\n", {}) + install(tmp_path, "Name: pytz\nVersion: 1.0\n", {}) + with pytest.warns(UserWarning, match=r"left out the requirement 'pytz \(>dev\)' of old: "): + by_name = {distribution.name: distribution for distribution in facts_from(tmp_path).distributions} + assert by_name["old"].depends_on == ["pytz"] + + +@pytest.mark.parametrize("commit", [pytest.param("a" * 40, id="sha1"), pytest.param("b" * 64, id="sha256")]) +def test_commit_from_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, commit: str) -> None: + monkeypatch.setenv("PYTHON_SBOM_COMMIT", commit) + assert default_revision(tmp_path) == Revision(commit=commit, dirty=False) + + +@pytest.mark.parametrize( + "commit", + [ + pytest.param("c0ffee", id="abbreviated"), + pytest.param("A" * 40, id="uppercase"), + pytest.param("a" * 41, id="between-lengths"), + pytest.param(f"{'a' * 40}/../../evil", id="path"), + ], +) +def test_commit_from_environment_must_be_full_hash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, commit: str +) -> None: + monkeypatch.setenv("PYTHON_SBOM_COMMIT", commit) + with pytest.raises(SettingsError, match="PYTHON_SBOM_COMMIT must be a full lowercase hex commit hash"): + default_revision(tmp_path) + + +def test_commit_from_environment_wins_over_git(checkout: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTHON_SBOM_COMMIT", "a" * 40) + assert default_revision(checkout) == Revision(commit="a" * 40, dirty=False) + + +@pytest.fixture +def checkout(tmp_path: Path, git: Git) -> Path: + (root := tmp_path / "checkout").joinpath("pyproject.toml").write_text("[project]\n", encoding="utf-8") + (root / ".gitignore").write_text("build/\n", encoding="utf-8") + git("add", ".") + git("commit", "--quiet", "--message", "first") + return root + + +def test_revision_file(tmp_path: Path) -> None: + (tmp_path / REVISION_FILE).write_text(f"node: {'d' * 40}\n", encoding="utf-8") + assert default_revision(tmp_path) == Revision(commit="d" * 40, dirty=False) + + +def test_invalid_revision_file(tmp_path: Path) -> None: + (tmp_path / REVISION_FILE).write_text("node: d\n", encoding="utf-8") + with pytest.raises(SettingsError, match=r"\.python-sbom-revision must be a full lowercase hex commit hash"): + default_revision(tmp_path) + + +def test_git_revision(checkout: Path, git: Git) -> None: + assert default_revision(checkout) == Revision(commit=git("rev-parse", "HEAD"), dirty=False) + + +def test_git_revision_dirty(checkout: Path, git: Git) -> None: + (checkout / "pyproject.toml").write_text("[project]\nname = 'changed'\n", encoding="utf-8") + assert git_revision(checkout) == Revision(commit=git("rev-parse", "HEAD"), dirty=True) + + +def test_untracked_file_marks_revision_dirty(checkout: Path) -> None: + # the build packages it, so the commit alone no longer describes the source + (checkout / "backdoor.py").write_text("import os\n", encoding="utf-8") + assert (revision := git_revision(checkout)) is not None + assert revision.dirty + + +def test_ignored_file_leaves_revision_clean(checkout: Path) -> None: + (checkout / "build").mkdir() + (checkout / "build" / "output.txt").write_text("new\n", encoding="utf-8") + assert (revision := git_revision(checkout)) is not None + assert not revision.dirty + + +@pytest.fixture +def monorepo(tmp_path: Path, git: Git) -> Path: + for project in ("one", "two"): + (root := tmp_path / "checkout" / project).mkdir() + (root / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + git("add", ".") + git("commit", "--quiet", "--message", "first") + return tmp_path / "checkout" + + +def test_git_revision_of_project_in_subdirectory(monorepo: Path, git: Git) -> None: + assert git_revision(monorepo / "one") == Revision(commit=git("rev-parse", "HEAD"), dirty=False) + + +def test_sibling_changes_leave_revision_clean(monorepo: Path) -> None: + (monorepo / "two" / "pyproject.toml").write_text("[project]\nname = 'changed'\n", encoding="utf-8") + (monorepo / "two" / "new.py").write_text("", encoding="utf-8") + assert (revision := git_revision(monorepo / "one")) is not None + assert not revision.dirty + + +def test_project_untracked_by_enclosing_checkout_uses_archival(checkout: Path) -> None: + # an sdist unpacked under another project's build directory is not that project's commit + (project := checkout / "build" / "project").mkdir(parents=True) + (project / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (project / ".git_archival.txt").write_text(f"node: {'e' * 40}\n", encoding="utf-8") + assert default_revision(project) == Revision(commit="e" * 40, dirty=False) + + +@pytest.mark.parametrize( + "project", + [pytest.param(".", id="own-checkout"), pytest.param("build/project", id="enclosing-checkout")], +) +def test_git_runs_no_fsmonitor_command(tmp_path: Path, checkout: Path, git: Git, project: str) -> None: + git("config", "core.fsmonitor", f"touch {(marker := tmp_path / 'ran').as_posix()}; false") + (root := checkout / project).mkdir(parents=True, exist_ok=True) + (root / "pyproject.toml").touch() + git_revision(root) + assert not marker.exists() + + +def test_git_revision_before_first_commit(tmp_path: Path, git: Git) -> None: + (tmp_path / "checkout" / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + git("add", "pyproject.toml") + assert git_revision(tmp_path / "checkout") is None + + +def test_git_revision_outside_checkout(tmp_path: Path) -> None: + assert git_revision(tmp_path) is None + + +def test_git_revision_without_git(checkout: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PATH", "") + assert git_revision(checkout) is None + + +@pytest.mark.parametrize( + "git_file", + [ + # a worktree copied without the main checkout, as into a container + pytest.param("gitdir: {root}/missing\n", id="missing-gitdir"), + # the marker uv leaves in an unpacked sdist it builds from + pytest.param("", id="empty"), + ], +) +def test_git_failure_warns_and_falls_back(tmp_path: Path, git_file: str) -> None: + (tmp_path / ".git").write_text(git_file.format(root=tmp_path.as_posix()), encoding="utf-8") + (tmp_path / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (tmp_path / ".git_archival.txt").write_text(f"node: {'f' * 40}\n", encoding="utf-8") + with pytest.warns(UserWarning, match="git cannot read the source revision in .*: fatal: "): + assert default_revision(tmp_path) == Revision(commit="f" * 40, dirty=False) + + +def test_git_status_failure_leaves_revision_unknown(checkout: Path, git: Git) -> None: + # a tree object lost from the store, so git knows HEAD but cannot compare the checkout with it + tree = git("rev-parse", "HEAD^{tree}") + (stored := checkout / ".git" / "objects" / tree[:2] / tree[2:]).chmod(0o644) + stored.unlink() + with pytest.warns(UserWarning, match="git cannot read the source revision in .*: error: bad tree object HEAD"): + assert git_revision(checkout) is None + + +@pytest.mark.parametrize( + ("archival", "expected"), + [ + pytest.param(f"node: {'a' * 40}\n", Revision(commit="a" * 40, dirty=False), id="expanded"), + pytest.param("node: $Format:%H$\n", None, id="unexpanded"), + pytest.param(f"node: {'a' * 41}\n", None, id="not-a-commit"), + ], +) +def test_git_archival(tmp_path: Path, archival: str, expected: Revision | None) -> None: + (tmp_path / ".git_archival.txt").write_text(archival, encoding="utf-8") + assert default_revision(tmp_path) == expected + + +def test_no_revision_source(tmp_path: Path) -> None: + assert default_revision(tmp_path) is None diff --git a/tests/_collect/test_settings.py b/tests/_collect/test_settings.py new file mode 100644 index 0000000..fa085b1 --- /dev/null +++ b/tests/_collect/test_settings.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._collect._settings import settings_from_pyproject, settings_from_table +from python_sbom._errors import SettingsError +from python_sbom._model._inputs import Settings + +if TYPE_CHECKING: + from pathlib import Path + + from python_sbom._collect._settings import Toml + + +def test_missing_pyproject(tmp_path: Path) -> None: + with pytest.raises(SettingsError, match=r"has no pyproject\.toml to read"): + settings_from_pyproject(tmp_path, {}) + + +def test_invalid_pyproject(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[tool\n", encoding="utf-8") + with pytest.raises(SettingsError, match=r"pyproject\.toml is not valid TOML"): + settings_from_pyproject(tmp_path, {}) + + +def test_defaults(tmp_path: Path, settings: Settings) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'a'\n", encoding="utf-8") + assert settings_from_pyproject(tmp_path, {}) == settings + + +def test_table_with_overrides(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.python-sbom]\n" + "repository = 'https://github.com/example/example/'\n" + "default-branch = 'dev'\n" + "bundled-wheels = ['src/*.whl']\n" + "supplier = { name = 'Example Org' }\n", + encoding="utf-8", + ) + assert settings_from_pyproject( + tmp_path, {"default-branch": "trunk", "content-provider": "sbom:Content"} + ) == Settings( + supplier={"name": "Example Org", "url": []}, + repository="https://github.com/example/example", + default_branch="trunk", + bundled_wheels=("src/*.whl",), + content_provider="sbom:Content", + ) + + +def test_unknown_option() -> None: + with pytest.raises(SettingsError, match=r"here has unknown option\(s\) bundled, suplier; known: bundled-wheels"): + settings_from_table({"suplier": {}, "bundled": []}, "here") + + +@pytest.mark.parametrize( + ("table", "message"), + [ + pytest.param({"repository": 1}, "`repository` in here must be a string", id="repository"), + pytest.param({"default-branch": 1}, "`default-branch` in here must be a string", id="default-branch"), + pytest.param({"bundled-wheels": "*.whl"}, "`bundled-wheels` in here must be an array", id="bundled-not-list"), + pytest.param({"bundled-wheels": [1]}, "`bundled-wheels` in here must be an array", id="bundled-not-strings"), + pytest.param({"content-provider": 1}, "`content-provider` in here must be a string", id="content-provider"), + pytest.param({"supplier": "Example"}, "`supplier` in here must be a table", id="supplier-not-table"), + pytest.param({"supplier": {"name": 1}}, "`supplier` in here must be a table", id="supplier-name"), + pytest.param({"supplier": {"name": "a", "url": "x"}}, "`supplier` in here must be a table", id="supplier-url"), + pytest.param( + {"supplier": {"name": "a", "email": "x"}}, "`supplier` in here must be a table", id="supplier-key" + ), + ], +) +def test_wrong_type(table: dict[str, Toml], message: str) -> None: + with pytest.raises(SettingsError, match=message): + settings_from_table(table, "here") + + +def test_supplier_with_urls() -> None: + settings = settings_from_table({"supplier": {"name": "Example Org", "url": ["https://example.org"]}}, "here") + assert settings.supplier == {"name": "Example Org", "url": ["https://example.org"]} diff --git a/tests/_collect/test_source.py b/tests/_collect/test_source.py new file mode 100644 index 0000000..84cc09a --- /dev/null +++ b/tests/_collect/test_source.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import base64 +import dataclasses +import hashlib +import re +import zipfile +from itertools import starmap +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._collect._source import read_bundled_wheel, read_source_tree, wheel_request +from python_sbom._errors import SettingsError, WheelError +from python_sbom._model._inputs import BuildScript, RecordRow, SbomRequest +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from collections.abc import Mapping + + from conftest import WriteWheel + + from python_sbom._model._inputs import Settings + +_INIT: Final[str] = "pip/__init__.py" +_METADATA: Final[str] = "pip-1.0.dist-info/METADATA" +_PIP: Final[dict[str, bytes]] = {_INIT: b"", _METADATA: b"Name: pip\nVersion: 1.0\n"} + + +# defined before the parametrized cases below, which build their RECORD rows with it at import time +def row(path: str, content: bytes, algorithm: str = "sha256", *, encoded: str = "", size: int | None = None) -> str: + encoded = encoded or base64.urlsafe_b64encode(hashlib.new(algorithm, content).digest()).rstrip(b"=").decode() + return f"{path},{algorithm}={encoded},{len(content) if size is None else size}\n" + + +_METADATA_ROW: Final[str] = row(_METADATA, _PIP[_METADATA]) +_RECORD_ROW: Final[str] = "pip-1.0.dist-info/RECORD,,\n" + + +@pytest.mark.parametrize( + ("files", "expected"), + [ + pytest.param({}, None, id="no-license-file"), + pytest.param({"LICENSE": "MIT terms\n"}, None, id="no-copyright-line"), + pytest.param( + {"LICENSE": "MIT\n\nCopyright (c) 2026 Example\nCopyright (c) 2025 Other\n"}, + "Copyright (c) 2026 Example", + id="first-copyright-line", + ), + ], +) +def test_copyright(tmp_path: Path, settings: Settings, files: Mapping[str, str], expected: str | None) -> None: + for name, text in files.items(): + (tmp_path / name).write_text(text, encoding="utf-8") + source = read_source_tree(request(tmp_path, settings, "License-File: LICENSE\nLicense-File: NOTICE\n")) + assert source.copyright == expected + + +def request(root: Path, settings: Settings, metadata: str = "", scripts: tuple[PurePosixPath, ...] = ()) -> SbomRequest: + return SbomRequest( + root=root, + metadata=parse_metadata(f"Name: example\nVersion: 1.0\n{metadata}"), + wheel_name="example-1.0-py3-none-any.whl", + settings=settings, + build_scripts=scripts, + ) + + +def test_bundled_wheels_sorted_once(tmp_path: Path, settings: Settings, write_wheel: WriteWheel) -> None: + for directory in ("b", "a"): + write_wheel(tmp_path / "src" / directory / "pip-1.0-py3-none-any.whl", _PIP) + settings = dataclasses.replace(settings, bundled_wheels=("src/*/*.whl", "src/a/*.whl")) + paths = [wheel.path for wheel in read_source_tree(request(tmp_path, settings)).bundled] + assert paths == ["src/a/pip-1.0-py3-none-any.whl", "src/b/pip-1.0-py3-none-any.whl"] + + +@pytest.mark.parametrize( + "pattern", + [ + pytest.param("../outside/*.whl", id="parent"), + pytest.param("src/../../outside/*.whl", id="parent-after-directory"), + pytest.param("ROOT/outside/*.whl", id="absolute"), + ], +) +def test_bundled_wheel_pattern_outside_project( + tmp_path: Path, settings: Settings, write_wheel: WriteWheel, pattern: str +) -> None: + write_wheel(tmp_path / "outside" / "pip-1.0-py3-none-any.whl", _PIP) + (project := tmp_path / "project" / "src").mkdir(parents=True) + pattern = pattern.replace("ROOT", tmp_path.as_posix()) + settings = dataclasses.replace(settings, bundled_wheels=(pattern,)) + with pytest.raises(SettingsError, match=f"`bundled-wheels` pattern {re.escape(repr(pattern))} must be relative"): + read_source_tree(request(project.parent, settings)) + + +def test_build_scripts_hashed(tmp_path: Path, settings: Settings) -> None: + (tmp_path / "hatch_build.py").write_bytes(b"hook = 1\n") + script = PurePosixPath("hatch_build.py") + source = read_source_tree(request(tmp_path, settings, scripts=(script,))) + assert source.build_scripts == [BuildScript(path=script, sha256=hashlib.sha256(b"hook = 1\n").hexdigest())] + + +def test_build_script_outside_project(tmp_path: Path, settings: Settings) -> None: + # a custom hook path may be absolute, which on Windows carries a drive the join with the root must keep + (tmp_path / "hatch_build.py").write_bytes(b"hook = 1\n") + script = PurePosixPath((tmp_path / "hatch_build.py").as_posix()) + source = read_source_tree(request(tmp_path / "project", settings, scripts=(script,))) + assert source.build_scripts == [BuildScript(path=script, sha256=hashlib.sha256(b"hook = 1\n").hexdigest())] + + +def test_bundled_wheel(tmp_path: Path, write_wheel: WriteWheel) -> None: + wheel = write_wheel( + tmp_path / "src" / "pip-1.0-py3-none-any.whl", + { + **_PIP, + "pip/_vendor/vendor.txt": b"# pinned\n\nurllib3==1.26.4 # http\n", + "pip/_vendor/rich-13.0.dist-info/METADATA": b"Name: rich\nVersion: 13.0\n", + }, + ) + bundled = read_bundled_wheel(wheel.read_bytes(), "src/pip-1.0-py3-none-any.whl") + assert (bundled.path, bundled.sha256, bundled.metadata_name, bundled.metadata.name, bundled.files[0]) == ( + "src/pip-1.0-py3-none-any.whl", + hashlib.sha256(wheel.read_bytes()).hexdigest(), + "pip-1.0.dist-info/METADATA", + "pip", + RecordRow(path="pip/__init__.py", algorithm="SHA-256", digest=hashlib.sha256(b"").hexdigest(), size="0"), + ) + assert [(source, metadata.raw) for source, metadata in bundled.vendored] == [ + ("pip/_vendor/vendor.txt", {"name": "urllib3", "version": "1.26.4"}), + ("pip/_vendor/rich-13.0.dist-info/METADATA", {"name": "rich", "version": "13.0"}), + ] + + +@pytest.mark.parametrize( + ("files", "message"), + [ + pytest.param( + {"pip/__init__.py": b""}, + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="no-dist-info", + ), + pytest.param({"pip-1.0.dist-info/WHEEL": b""}, "has no pip-1.0.dist-info/METADATA", id="no-metadata"), + pytest.param( + {"aaa-0.dist-info/METADATA": b"Name: aaa\nVersion: 0\n", **_PIP}, + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="two-dist-info", + ), + pytest.param({"pip-1.0.dist-info/METADATA": b"Name: pip\n"}, "METADATA lacks Name or Version", id="no-version"), + pytest.param( + {"pip-1.0.dist-info/METADATA": _PIP["pip-1.0.dist-info/METADATA"]}, + "has no pip-1.0.dist-info/RECORD", + id="no-record", + ), + pytest.param( + {"pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\n" + b" " * 16 * 1024 * 1024}, + "METADATA is over the 16777216 byte limit", + id="oversized-metadata", + ), + pytest.param( + {**_PIP, "pip-1.0.dist-info/RECORD": b"\n" * (16 * 1024 * 1024 + 1)}, + "RECORD is over the 16777216 byte limit", + id="oversized-record", + ), + ], +) +def test_malformed_bundled_wheel( + tmp_path: Path, write_wheel: WriteWheel, files: Mapping[str, bytes], message: str +) -> None: + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + with pytest.raises(WheelError, match=message): + read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +@pytest.mark.parametrize( + ("name", "message"), + [ + pytest.param("pip.whl", "Not a valid wheel filename: pip.whl", id="unparsable"), + pytest.param( + "other-1.0-py3-none-any.whl", r"Wheel \.dist-info directory doesn't match wheel filename", id="other" + ), + ], +) +def test_bundled_wheel_file_name_names_the_project( + tmp_path: Path, write_wheel: WriteWheel, name: str, message: str +) -> None: + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", _PIP) + with pytest.raises(WheelError, match=f"bundled wheel {name} is not a wheel: {message}"): + read_bundled_wheel(wheel.read_bytes(), name) + + +def test_corrupt_bundled_wheel() -> None: + with pytest.raises(WheelError, match=r"bundled wheel pip\.whl is not a valid zip archive"): + read_bundled_wheel(b"not a zip", "pip.whl") + + +def test_bundled_wheel_with_duplicate_member(tmp_path: Path) -> None: + # installers extract the first copy, so a RECORD that hashes the second one would hide the installed code + with zipfile.ZipFile(wheel := tmp_path / "pip-1.0-py3-none-any.whl", "w") as archive: + archive.writestr(_INIT, b"import os\n") + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr(_INIT, b"") + archive.writestr(_METADATA, _PIP[_METADATA]) + archive.writestr("pip-1.0.dist-info/RECORD", f"{row(_INIT, b'')}{_METADATA_ROW}") + with pytest.raises(WheelError, match=f"holds more than one member named {_INIT}, ignoring case"): + read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +@pytest.mark.parametrize( + ("algorithm", "expected"), + [ + pytest.param("sha256", "SHA-256", id="sha256"), + pytest.param("sha384", "SHA-384", id="sha384"), + pytest.param("sha512", "SHA-512", id="sha512"), + ], +) +def test_bundled_wheel_record_algorithm(tmp_path: Path, write_wheel: WriteWheel, algorithm: str, expected: str) -> None: + record = "".join(row(path, content, algorithm) for path, content in _PIP.items()) + _RECORD_ROW + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode()} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + assert [ + (file.path, file.algorithm, file.digest) + for file in read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl").files + ] == [(path, expected, hashlib.new(algorithm, content).hexdigest()) for path, content in _PIP.items()] + + +@pytest.mark.parametrize( + ("record", "message"), + [ + pytest.param( + f"{_INIT},sha256=x\n", "RECORD does not parse: Row Index 0: expected 3 elements, got 2", id="columns" + ), + pytest.param( + f"{_METADATA_ROW}\n{_RECORD_ROW}", + "RECORD does not parse: Row Index 1: expected 3 elements, got 0", + id="blank-line", + ), + pytest.param( + f"{_INIT},,0\n{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD disagrees with the wheel: hash / size of {_INIT} is not included in RECORD", + id="no-digest", + ), + pytest.param( + f"{row(_INIT, b'')}{_METADATA_ROW}", + "RECORD disagrees with the wheel: pip-1.0.dist-info/RECORD is not mentioned in RECORD", + id="record-not-recorded", + ), + pytest.param( + f"{row(_INIT, b'', 'sha224')}{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD row for {_INIT}: unknown hash algorithm 'sha224'", + id="algorithm-cyclonedx-lacks", + ), + pytest.param(_METADATA_ROW, f"RECORD lacks a digest for {_INIT}", id="member-not-recorded"), + pytest.param( + f"{row(_INIT, b'', encoded='A' * 43)}{row(_INIT, b'')}{_METADATA_ROW}", + f"RECORD lists {_INIT} more than once", + id="listed-twice", + ), + pytest.param( + f"{row(_INIT, b'')}{row(_INIT, b'', 'sha512', encoded='A' * 86)}{_METADATA_ROW}", + f"RECORD lists {_INIT} more than once", + id="listed-twice-by-other-algorithm", + ), + pytest.param( + f"{row(_INIT, b'')}{row('pip/ghost.py', b'', 'md5', size=99)}{_METADATA_ROW}", + "RECORD lists pip/ghost.py, which the wheel does not hold", + id="recorded-not-member", + ), + pytest.param( + f"{row(_INIT, b'', size=999999)}{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD disagrees with the wheel: hash / size of {_INIT} didn't match RECORD", + id="size-mismatch", + ), + pytest.param( + f"{row(_INIT, b'', 'sha512', encoded='A' * 86)}{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD disagrees with the wheel: hash / size of {_INIT} didn't match RECORD", + id="digest-mismatch", + ), + ], +) +def test_record_disagrees_with_wheel(tmp_path: Path, write_wheel: WriteWheel, record: str, message: str) -> None: + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode()} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + with pytest.raises(WheelError, match=message): + read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +def test_record_signatures_need_no_row(tmp_path: Path, write_wheel: WriteWheel) -> None: + record = "".join(starmap(row, _PIP.items())) + _RECORD_ROW + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode(), "pip-1.0.dist-info/RECORD.jws": b"{}"} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + assert [file.path for file in read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl").files] == list( + _PIP + ) + + +@pytest.mark.parametrize( + "signature", + [ + pytest.param("pip-1.0.dist-info/sub/RECORD.jws", id="nested"), + pytest.param("pip-1.0.dist-info/../pip/RECORD.p7s", id="parent"), + ], +) +def test_signature_away_from_record_needs_a_row(tmp_path: Path, write_wheel: WriteWheel, signature: str) -> None: + # installer lets these go unlisted, though a signature only sits beside RECORD + record = "".join(starmap(row, _PIP.items())) + _RECORD_ROW + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode(), signature: b"import os\n"} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + with pytest.raises(WheelError, match=f"RECORD lacks a digest for {re.escape(signature)}"): + read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +def test_vendored_metadata_needs_name_and_version(tmp_path: Path, write_wheel: WriteWheel) -> None: + files = {**_PIP, "pip/_vendor/rich-13.0.dist-info/METADATA": b"Metadata-Version: 2.1\n"} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files) + with pytest.raises(WheelError, match=r"pip/_vendor/rich-13\.0\.dist-info/METADATA lacks Name or Version"): + read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +@pytest.mark.parametrize( + ("manifest", "message"), + [ + pytest.param(b"urllib3 ==\n", "does not parse: urllib3 ==", id="unparsable"), + pytest.param(b"urllib3>=1\n", "pins no one exact version: urllib3>=1", id="range"), + pytest.param(b"urllib3==1.*\n", "pins no one exact version: urllib3==1.*", id="wildcard"), + pytest.param(b"urllib3==1,!=1.1\n", "pins no one exact version: urllib3==1,!=1.1", id="two-specifiers"), + ], +) +def test_vendor_manifest_needs_exact_pins( + tmp_path: Path, write_wheel: WriteWheel, manifest: bytes, message: str +) -> None: + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", {**_PIP, "pip/_vendor/vendor.txt": manifest}) + with pytest.raises(WheelError, match=message): + read_bundled_wheel(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +def test_wheel_request(tmp_path: Path, settings: Settings, write_wheel: WriteWheel) -> None: + wheel = write_wheel(tmp_path / "native-1.0-py3-none-any.whl", {"native-1.0.dist-info/METADATA": b"Name: native\n"}) + request = wheel_request(wheel, tmp_path, settings, (PurePosixPath("build.py"),)) + assert (request.root, request.metadata.name, request.wheel_name, request.settings, request.build_scripts) == ( + tmp_path, + "native", + "native-1.0-py3-none-any.whl", + settings, + (PurePosixPath("build.py"),), + ) + + +@pytest.mark.parametrize( + ("name", "message"), + [ + pytest.param("native.whl", "is not a wheel: Not a valid wheel filename", id="file-name"), + pytest.param( + "native-1.0-py3-none-any.whl", + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="no-dist-info", + ), + ], +) +def test_wheel_request_needs_a_wheel( + tmp_path: Path, settings: Settings, write_wheel: WriteWheel, name: str, message: str +) -> None: + archive = write_wheel(tmp_path / name, {"native/__init__.py": b""}, record=False) + with pytest.raises(WheelError, match=message): + wheel_request(archive, Path(), settings, ()) diff --git a/tests/_formats/test_spdx.py b/tests/_formats/test_spdx.py new file mode 100644 index 0000000..4078f57 --- /dev/null +++ b/tests/_formats/test_spdx.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import dataclasses +import json +import re +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Final, TypeAlias + +import pytest + +from python_sbom._formats._json import dumps +from python_sbom._formats._spdx import render_spdx, to_spdx +from python_sbom._validate._schemas import spdx_problems + +if TYPE_CHECKING: + from conftest import Describe + + from python_sbom import Component + from python_sbom._formats._spdx import _Package + from python_sbom._model._inputs import Settings + +# a JSON value, as json.loads hands it over +_Json: TypeAlias = "str | int | float | bool | list[_Json] | dict[str, _Json] | None" + +# what auditwheel writes: CycloneDX 1.4 conventions, a legacy tools array, no timestamp and no serial number +_FOREIGN: Final[str] = json.dumps({ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": {"type": "library", "bom-ref": "pkg:pypi/plain@1.0", "name": "plain", "version": "1.0"}, + "tools": [{"name": "auditwheel", "version": "6.4.0"}, {"name": "repair"}, {"vendor": "PyPA"}], + }, + "components": [{"type": "library", "bom-ref": "pkg:rpm/almalinux/libfoo@1", "name": "libfoo", "version": "1"}], + "dependencies": [{"ref": "pkg:pypi/plain@1.0", "dependsOn": ["pkg:rpm/almalinux/libfoo@1"]}], +}) + + +def test_minimal(describe: Describe) -> None: + document = describe() + spdx = render_spdx(document, "1.2.3") + ids = {package["name"]: package["SPDXID"] for package in spdx["packages"]} + serial = document["serialNumber"] + assert spdx == { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "Example-1.0", + "documentNamespace": f"https://spdx.org/spdxdocs/sboms/Example-1.0-{serial.removeprefix('urn:uuid:')}", + "comment": f"Rendered from the CycloneDX SBOM {serial} (example.cdx.json), which also carries per-file hashes" + " and the build formulation.", + "creationInfo": {"created": "2023-11-14T22:13:20Z", "creators": ["Tool: python-sbom-1.2.3"]}, + "packages": [ + { + "SPDXID": ids["Example"], + "name": "Example", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "primaryPackagePurpose": "APPLICATION", + "versionInfo": "1.0", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/example@1.0", + } + ], + }, + { + "SPDXID": ids["cpython"], + "name": "cpython", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "primaryPackagePurpose": "OTHER", + "versionInfo": "3.15.0", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/cpython@3.15.0", + } + ], + }, + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": ids["Example"], + }, + { + "spdxElementId": ids["cpython"], + "relationshipType": "BUILD_TOOL_OF", + "relatedSpdxElement": ids["Example"], + }, + ], + } + + +def test_spdx_ids_are_valid_and_tell_similar_refs_apart(describe: Describe) -> None: + document = describe("Requires-Dist: click; os_name == 'nt'\nRequires-Dist: click; os_name != 'nt'\n") + ids = [package["SPDXID"] for package in render_spdx(document, "1")["packages"]] + assert all(re.fullmatch(r"SPDXRef-[A-Za-z0-9.-]+", spdx_id) for spdx_id in ids) + assert len(set(ids)) == len(ids) + + +def test_repository_names_the_namespace(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, repository="https://github.com/example/example")) + namespace = render_spdx(document, "1")["documentNamespace"] + assert namespace.startswith("https://github.com/example/example/sboms/Example-1.0-") + + +def test_supplier_and_application_tools_are_creators(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, supplier={"name": "Example Org", "url": []})) + document["metadata"]["tools"]["components"].extend([ + {"type": "application", "bom-ref": "tool:hatchling", "name": "hatchling", "version": "1.28"}, + {"type": "application", "bom-ref": "tool:script", "name": "script"}, + ]) + assert render_spdx(document, "1")["creationInfo"]["creators"] == [ + "Organization: Example Org", + "Tool: hatchling-1.28", + "Tool: script", + "Tool: python-sbom-1", + ] + + +def test_versionless_root(describe: Describe) -> None: + document = describe() + del document["metadata"]["component"]["version"] + spdx = render_spdx(document, "1") + assert spdx["name"] == "Example" + assert "versionInfo" not in spdx["packages"][0] + + +def test_package_fields(describe: Describe) -> None: + document = describe() + component: Component = { + "type": "library", + "bom-ref": "bundled:a.whl", + "name": "a", + "supplier": {"name": "PyPA", "url": []}, + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "externalReferences": [{"type": "website", "url": "https://example.org"}], + "copyright": "Copyright (c) PyPA", + "description": "a library", + "licenses": [ + {"license": {"name": "BSD License", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ], + "properties": [{"name": "example:bundled-wheel", "value": "a.whl"}, {"name": "python:keywords", "value": "a"}], + "components": [{"type": "file", "bom-ref": "bundled:a.whl#a.py", "name": "a.py"}], + } + document["components"].append(component) + (package,) = (package for package in render_spdx(document, "1")["packages"] if package["name"] == "a") + assert {key: value for key, value in package.items() if key != "SPDXID"} == { + "name": "a", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "MIT", + "licenseDeclared": "NOASSERTION", + "licenseComments": "Declared in package metadata as: BSD License", + "primaryPackagePurpose": "LIBRARY", + "supplier": "Organization: PyPA", + "checksums": [{"algorithm": "SHA256", "checksumValue": "ab" * 32}], + "homepage": "https://example.org", + "copyrightText": "Copyright (c) PyPA", + "summary": "a library", + "comment": "example:bundled-wheel: a.whl", + } + + +def test_license_refs_carry_extracted_text(describe: Describe) -> None: + document = describe("License-Expression: MIT AND LicenseRef-Vendor\n") + assert render_spdx(document, "1").get("hasExtractedLicensingInfos") == [ + { + "licenseId": "LicenseRef-Vendor", + "extractedText": "LicenseRef-Vendor, as named in package metadata", + "name": "LicenseRef-Vendor", + } + ] + + +def test_nested_libraries_are_contained(describe: Describe) -> None: + document = describe() + document["components"].append({ + "type": "library", + "bom-ref": "outer", + "name": "outer", + "components": [{"type": "library", "bom-ref": "inner", "name": "inner"}], + }) + spdx = render_spdx(document, "1") + ids = {package["name"]: package["SPDXID"] for package in spdx["packages"]} + assert {"spdxElementId": ids["outer"], "relationshipType": "CONTAINS", "relatedSpdxElement": ids["inner"]} in spdx[ + "relationships" + ] + + +@pytest.mark.parametrize( + ("kind", "purpose"), + [ + pytest.param("application", "APPLICATION", id="application"), + pytest.param("library", "LIBRARY", id="library"), + pytest.param("framework", "OTHER", id="other"), + ], +) +def test_package_purpose(describe: Describe, kind: str, purpose: str) -> None: + document = describe() + document["components"].append({"type": kind, "bom-ref": "extra", "name": "extra"}) + (package,) = (package for package in render_spdx(document, "1")["packages"] if package["name"] == "extra") + assert package["primaryPackagePurpose"] == purpose + + +def test_to_spdx_text(describe: Describe) -> None: + text = to_spdx(describe()) + assert (text.endswith("}\n"), json.loads(text)["spdxVersion"]) == (True, "SPDX-2.3") + + +def test_other_tools_document_renders_valid_spdx() -> None: + assert spdx_problems(render_spdx(json.loads(_FOREIGN), "1")) == [] + + +def test_legacy_tools_become_creators() -> None: + assert render_spdx(json.loads(_FOREIGN), "1")["creationInfo"]["creators"] == [ + "Tool: auditwheel-6.4.0", + "Tool: repair", + "Tool: python-sbom-1", + ] + + +def test_document_without_tools() -> None: + foreign = json.loads(_FOREIGN) + del foreign["metadata"]["tools"] + assert render_spdx(foreign, "1")["creationInfo"]["creators"] == ["Tool: python-sbom-1"] + + +def test_namespace_without_serial_names_the_content() -> None: + changed = json.loads(_FOREIGN) + changed["version"] = 2 + namespaces = [render_spdx(document, "1")["documentNamespace"] for document in (json.loads(_FOREIGN),) * 2] + assert (namespaces[0] == namespaces[1], namespaces[0] == render_spdx(changed, "1")["documentNamespace"]) == ( + True, + False, + ) + + +def test_creation_time_without_timestamp_is_the_rendering_time() -> None: + before = datetime.now(UTC).replace(microsecond=0) + created = datetime.fromisoformat(render_spdx(json.loads(_FOREIGN), "1")["creationInfo"]["created"]) + assert before <= created <= datetime.now(UTC) + + +def test_supplier_without_name_is_no_creator(describe: Describe) -> None: + # the schema makes the name optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + loaded["metadata"]["supplier"] = {"url": ["https://example.org"]} + assert render_spdx(loaded, "1")["creationInfo"]["creators"] == ["Tool: python-sbom-1"] + + +@pytest.mark.parametrize( + ("algorithm", "expected"), + [ + pytest.param("SHA-1", "SHA1", id="sha1"), + pytest.param("SHA-512", "SHA512", id="sha512"), + pytest.param("SHA3-256", "SHA3-256", id="sha3"), + pytest.param("BLAKE2b-256", "BLAKE2b-256", id="blake2b"), + pytest.param("BLAKE3", "BLAKE3", id="blake3"), + ], +) +def test_hash_algorithms_take_spdx_names(algorithm: str, expected: str) -> None: + (package,) = packages_of({"hashes": [{"alg": algorithm, "content": "ab" * 32}]}) + assert package.get("checksums") == [{"algorithm": expected, "checksumValue": "ab" * 32}] + + +def packages_of(fields: dict[str, _Json]) -> list[_Package]: + document = json.loads(_FOREIGN) + document["components"] = [{"type": "library", "bom-ref": "lib", "name": "lib", **fields}] + return [package for package in render_spdx(document, "1")["packages"] if package["name"] == "lib"] + + +def test_hash_algorithm_spdx_lacks_is_dropped() -> None: + (package,) = packages_of({"hashes": [{"alg": "SHA-224", "content": "ab" * 28}]}) + assert "checksums" not in package + + +def test_component_supplier_without_name_is_dropped() -> None: + (package,) = packages_of({"supplier": {"url": ["https://example.org"]}}) + assert "supplier" not in package + + +def test_property_without_value() -> None: + (package,) = packages_of({"properties": [{"name": "plain:extra"}]}) + assert package["comment"] == "plain:extra: " diff --git a/tests/_generate/pinned/embed-members.json b/tests/_generate/pinned/embed-members.json new file mode 100644 index 0000000..f7d3139 --- /dev/null +++ b/tests/_generate/pinned/embed-members.json @@ -0,0 +1,100 @@ +[ + [ + "pkg_name/__init__.py", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "9e26bf369911c45c243c684147b23fc9e1dcfcf257d299a1c632016a6fcd33f4" + ], + [ + "pkg_name/wheels/pip-1.0-py3-none-any.whl", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "a64ff1b762e23c4eb0a3b4a8fb85fe12d18de3dce86ea6471f3f546285976734" + ], + [ + "pkg_name-1.0.dist-info/METADATA", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "f18fd332176c30ba5df08ce78bd605a6616ed6e7e5e165a4a0b05e6db880b0a2" + ], + [ + "pkg_name-1.0.dist-info/licenses/LICENSE", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "d0febe6f2e066329c487c2621cd02a4eddce97b51cdacb22600dd5181d843c08" + ], + [ + "pkg_name-1.0.dist-info/WHEEL", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "242557f73f15fa3b36695e6a9904761377e20acf98bb7bcf3bdd5c08198ed493" + ], + [ + "pkg_name-1.0.dist-info/sboms/pkg-name.cdx.json", + [ + 2023, + 11, + 14, + 22, + 13, + 20 + ], + 8, + 2175008768, + "63503a8e31088e81123f5b7861da0411bc423174c7353aa74d2d1f9391ce23e4" + ], + [ + "pkg_name-1.0.dist-info/RECORD", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "a932f8430d0fec72d6bdfc1aa0afa1700ede4c4b6eec6a825e245e9835ed0a82" + ] +] diff --git a/tests/_generate/pinned/embed.cdx.json b/tests/_generate/pinned/embed.cdx.json new file mode 100644 index 0000000..2df0c7b --- /dev/null +++ b/tests/_generate/pinned/embed.cdx.json @@ -0,0 +1,583 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:d3cce085-f38d-5e88-a7b1-b1b95274c6f3", + "version": 1, + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [ + { + "phase": "build" + } + ], + "tools": { + "components": [ + { + "type": "platform", + "bom-ref": "tool:pkg:generic/cpython@3.14.0", + "name": "cpython", + "version": "3.14.0", + "purl": "pkg:generic/cpython@3.14.0", + "properties": [ + { + "name": "python:implementation", + "value": "CPython" + } + ] + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/alpha@1.0", + "name": "alpha", + "version": "1.0", + "purl": "pkg:pypi/alpha@1.0", + "externalReferences": [], + "properties": [ + { + "name": "python:requires-dist", + "value": "beta" + } + ], + "components": [ + { + "type": "file", + "bom-ref": "tool:pkg:pypi/alpha@1.0#alpha.py", + "name": "alpha.py", + "hashes": [ + { + "alg": "SHA-256", + "content": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "properties": [ + { + "name": "size", + "value": "0" + } + ] + }, + { + "type": "file", + "bom-ref": "tool:pkg:pypi/alpha@1.0#alpha-1.0.dist-info/METADATA", + "name": "alpha-1.0.dist-info/METADATA", + "hashes": [ + { + "alg": "SHA-256", + "content": "84fff1197cd817e092c798eaadecbb239d6076bda206362533d29a5151059eb1" + } + ], + "properties": [ + { + "name": "size", + "value": "45" + } + ] + }, + { + "type": "file", + "bom-ref": "tool:pkg:pypi/alpha@1.0#alpha-1.0.dist-info/WHEEL", + "name": "alpha-1.0.dist-info/WHEEL", + "hashes": [ + { + "alg": "SHA-256", + "content": "a4e0b653593a8427ffbe465eee1dac9c6456c7e0b6d178ee7376b4d8a427d8e6" + } + ], + "properties": [ + { + "name": "size", + "value": "41" + } + ] + } + ] + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/beta@2.0", + "name": "beta", + "version": "2.0", + "purl": "pkg:pypi/beta@2.0", + "externalReferences": [], + "properties": [] + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/gamma@3.0", + "name": "gamma", + "version": "3.0", + "purl": "pkg:pypi/gamma@3.0", + "externalReferences": [], + "properties": [] + } + ] + }, + "component": { + "type": "application", + "bom-ref": "pkg:pypi/pkg.name@1.0", + "name": "Pkg.Name", + "version": "1.0", + "purl": "pkg:pypi/pkg.name@1.0", + "description": "a pinned example", + "authors": [ + { + "name": "not-an-email" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0", + "acknowledgement": "declared" + } + ], + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/example/pkg", + "comment": "Project-URL: Source" + }, + { + "type": "vcs", + "url": "https://gitlab.com/example/pkg", + "comment": "Project-URL: Source" + }, + { + "type": "documentation", + "url": "https://pkg.readthedocs.io", + "comment": "Project-URL: Documentation" + }, + { + "type": "issue-tracker", + "url": "https://github.com/example/pkg/issues", + "comment": "Project-URL: Bug Tracker" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/Pkg.Name/1.0/" + }, + { + "type": "attestation", + "url": "https://pypi.org/integrity/Pkg.Name/1.0/pkg_name-1.0-py3-none-any.whl/provenance" + }, + { + "type": "security-contact", + "url": "https://github.com/example/pkg/security/policy" + }, + { + "type": "advisories", + "url": "https://github.com/example/pkg/security/advisories" + }, + { + "type": "license", + "url": "https://github.com/example/pkg/blob/main/LICENSE" + }, + { + "type": "vcs", + "url": "https://github.com/example/pkg/tree/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "comment": "exact source revision" + } + ], + "properties": [ + { + "name": "python:requires-dist", + "value": "alpha>=1" + }, + { + "name": "python:requires-dist", + "value": "beta; extra == \"cli\"" + }, + { + "name": "python:requires-dist", + "value": "gamma; python_version < \"3\"" + }, + { + "name": "python:requires-dist", + "value": "delta; extra in \"a,b\"" + }, + { + "name": "python:provides-extra", + "value": "cli" + }, + { + "name": "python:classifier", + "value": "License :: OSI Approved :: MIT License" + }, + { + "name": "python:classifier", + "value": "Programming Language :: Python :: 3" + }, + { + "name": "python:keywords", + "value": "sbom,pinned" + }, + { + "name": "pkg-name:vcs-commit", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "supplier": { + "name": "Example Org", + "url": [ + "https://example.org" + ] + } + }, + "manufacturer": { + "name": "Example Org", + "url": [ + "https://example.org" + ] + }, + "supplier": { + "name": "Example Org", + "url": [ + "https://example.org" + ] + }, + "authors": [ + { + "name": "not-an-email" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0", + "acknowledgement": "declared" + } + ], + "properties": [ + { + "name": "pkg-name:sbom:scope", + "value": "Components are the wheels bundled as data files under src/pkg/wheels and the direct runtime dependencies declared in the wheel's core metadata, which carry no version because the installer resolves them." + }, + { + "name": "pkg-name:sbom:file", + "value": "pkg-name.cdx.json" + }, + { + "name": "pkg-name:sbom:repository", + "value": "https://github.com/example/pkg" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl", + "name": "pip", + "version": "1.0", + "purl": "pkg:pypi/pip@1.0", + "licenses": [ + { + "expression": "MIT", + "acknowledgement": "declared" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/pip/1.0/" + } + ], + "properties": [ + { + "name": "pkg-name:bundled-wheel", + "value": "pkg_name/wheels/pip-1.0-py3-none-any.whl" + } + ], + "hashes": [ + { + "alg": "SHA-256", + "content": "a64ff1b762e23c4eb0a3b4a8fb85fe12d18de3dce86ea6471f3f546285976734" + } + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "pip-1.0.dist-info/METADATA" + } + ] + }, + { + "field": "hash", + "confidence": 1, + "methods": [ + { + "technique": "hash-comparison", + "confidence": 1, + "value": "a64ff1b762e23c4eb0a3b4a8fb85fe12d18de3dce86ea6471f3f546285976734" + } + ] + } + ] + }, + "components": [ + { + "type": "file", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#pip/__init__.py", + "name": "pip/__init__.py", + "hashes": [ + { + "alg": "SHA-256", + "content": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "properties": [ + { + "name": "size", + "value": "0" + } + ] + }, + { + "type": "file", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#pip/_vendor/vendor.txt", + "name": "pip/_vendor/vendor.txt", + "hashes": [ + { + "alg": "SHA-256", + "content": "26c04392ab4b025968e4dbdaca5fcc4ec977c70607bb02d4ae7bc724d4ba92ea" + } + ], + "properties": [ + { + "name": "size", + "value": "26" + } + ] + }, + { + "type": "file", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#pip-1.0.dist-info/METADATA", + "name": "pip-1.0.dist-info/METADATA", + "hashes": [ + { + "alg": "SHA-256", + "content": "3ea62ee2e6d4513f0a06630188f10dbeace9ed76fd29897bf76edd496d948e37" + } + ], + "properties": [ + { + "name": "size", + "value": "69" + } + ] + }, + { + "type": "library", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/idna@3.7", + "name": "idna", + "version": "3.7", + "purl": "pkg:pypi/idna@3.7", + "externalReferences": [], + "properties": [ + { + "name": "pkg-name:vendored-manifest", + "value": "pip/_vendor/vendor.txt" + } + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "pip/_vendor/vendor.txt" + } + ] + } + ] + } + }, + { + "type": "library", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/urllib3@1.26.4", + "name": "urllib3", + "version": "1.26.4", + "purl": "pkg:pypi/urllib3@1.26.4", + "externalReferences": [], + "properties": [ + { + "name": "pkg-name:vendored-manifest", + "value": "pip/_vendor/vendor.txt" + } + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "pip/_vendor/vendor.txt" + } + ] + } + ] + } + } + ] + }, + { + "type": "library", + "bom-ref": "requires-dist:alpha>=1", + "name": "alpha", + "purl": "pkg:pypi/alpha", + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/alpha/" + } + ], + "properties": [ + { + "name": "pkg-name:requires-dist", + "value": "alpha>=1" + } + ] + }, + { + "type": "library", + "bom-ref": "requires-dist:gamma; python_version < \"3\"", + "name": "gamma", + "purl": "pkg:pypi/gamma", + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/gamma/" + } + ], + "properties": [ + { + "name": "pkg-name:requires-dist", + "value": "gamma; python_version < \"3\"" + }, + { + "name": "pkg-name:environment-marker", + "value": "python_version < \"3\"" + } + ] + }, + { + "type": "library", + "bom-ref": "requires-dist:delta; extra in \"a,b\"", + "name": "delta", + "purl": "pkg:pypi/delta", + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/delta/" + } + ], + "properties": [ + { + "name": "pkg-name:requires-dist", + "value": "delta; extra in \"a,b\"" + }, + { + "name": "pkg-name:environment-marker", + "value": "extra in \"a,b\"" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/pkg.name@1.0", + "dependsOn": [ + "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl", + "requires-dist:alpha>=1", + "requires-dist:gamma; python_version < \"3\"", + "requires-dist:delta; extra in \"a,b\"" + ] + }, + { + "ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl", + "dependsOn": [ + "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/idna@3.7", + "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/urllib3@1.26.4" + ] + }, + { + "ref": "tool:pkg:pypi/alpha@1.0", + "dependsOn": [ + "tool:pkg:pypi/beta@2.0" + ] + }, + { + "ref": "tool:pkg:pypi/beta@2.0", + "dependsOn": [] + }, + { + "ref": "tool:pkg:pypi/gamma@3.0", + "dependsOn": [] + } + ], + "compositions": [ + { + "aggregate": "incomplete", + "dependencies": [ + "pkg:pypi/pkg.name@1.0" + ] + } + ], + "formulation": [ + { + "bom-ref": "formula:wheel-build", + "workflows": [ + { + "bom-ref": "workflow:wheel-build", + "uid": "wheel-build", + "name": "build the wheel and this SBOM", + "taskTypes": [ + "build" + ], + "resourceReferences": [ + { + "ref": "tool:pkg:generic/cpython@3.14.0" + }, + { + "ref": "tool:pkg:pypi/alpha@1.0" + }, + { + "ref": "tool:pkg:pypi/beta@2.0" + }, + { + "ref": "tool:pkg:pypi/gamma@3.0" + } + ], + "outputs": [ + { + "type": "artifact", + "resource": { + "ref": "pkg:pypi/pkg.name@1.0" + } + } + ], + "inputs": [ + { + "environmentVars": [ + { + "name": "SOURCE_DATE_EPOCH", + "value": "1700000000" + } + ] + } + ] + } + ] + } + ] +} diff --git a/tests/_generate/test_archive.py b/tests/_generate/test_archive.py new file mode 100644 index 0000000..aa7f755 --- /dev/null +++ b/tests/_generate/test_archive.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import io +import json +import zipfile +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom import BundledWheelInfo, ContentProvider, Contributions, ProviderContext +from python_sbom._collect._facts import collect_facts +from python_sbom._errors import WheelError +from python_sbom._generate._archive import describe_archive +from python_sbom._generate._embed import revision_reader +from python_sbom._validate._artifact import validate + +if TYPE_CHECKING: + from conftest import WriteWheel + + from python_sbom import Component + from python_sbom._model._types import Document + +_COMMIT: Final[str] = "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff" +_LIBRARY: Final[str] = "__bundled__/distlib-0.4-py3-none-any" +_WHEEL: Final[str] = "app/wheels/pip-1.0-py3-none-any.whl" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_text( + "[tool.python-sbom]\nsupplier = { name = 'Example Org' }\nrepository = 'https://github.com/example/app'\n", + encoding="utf-8", + ) + (root / "build_app.py").write_text("print('build')\n", encoding="utf-8") + return root + + +@pytest.fixture +def archive(tmp_path: Path, write_wheel: WriteWheel) -> Path: + wheel = write_wheel( + tmp_path / "pip-1.0-py3-none-any.whl", + {"pip/__init__.py": b"", "pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\nLicense-Expression: MIT\n"}, + ) + with zipfile.ZipFile(target := tmp_path / "app.pyz", "w") as handle: + handle.writestr("__main__.py", "import app\n") + handle.writestr("loaded.json", json.dumps({_LIBRARY: ["3.14"]})) + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\nLicense-Expression: MIT\n") + handle.writestr("app/__init__.py", "") + handle.writestr("app/", "") + handle.writestr(_WHEEL, wheel.read_bytes()) + handle.writestr( + f"{_LIBRARY}/distlib-0.4.dist-info/METADATA", "Name: distlib\nVersion: 0.4\nLicense-Expression: MIT\n" + ) + handle.writestr(f"{_LIBRARY}/distlib/__init__.py", "") + handle.writestr("app.pyz.cdx.json", "{}") + return target + + +@pytest.fixture +def document(project: Path, archive: Path) -> Document: + facts = collect_facts(project, revision_reader(_COMMIT)) + return describe_archive( + project, archive, facts=facts, content=Bundles("loaded.json"), build_scripts=(PurePosixPath("build_app.py"),) + ) + + +class Bundles(ContentProvider): + def __init__(self, table: str) -> None: + self._table = table + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + # reads the archive, as a real provider reads its tables + loaded = json.loads(context.read(PurePosixPath(self._table))) + return BundledWheelInfo( + properties=[{"name": "app:loaded-for", "value": python} for python in loaded.get(str(wheel), [])], + supplier=context.settings.supplier, + ) + + def contributions(self, context: ProviderContext) -> Contributions: + zlib: Component = {"type": "library", "bom-ref": "pkg:generic/zlib@1.3", "name": "zlib", "version": "1.3"} + return Contributions( + components=[zlib], + dependencies=[{"ref": "pkg:generic/zlib@1.3", "dependsOn": []}], + root_properties=[ + {"name": "extra:project", "value": context.root.name}, + {"name": "extra:table", "value": self._table}, + ], + ) + + +def test_archive_document_validates(document: Document, tmp_path: Path) -> None: + (sbom := tmp_path / "app.pyz.cdx.json").write_text(json.dumps(document), encoding="utf-8") + assert validate(sbom, frozenset({"MIT"})) == [] + + +def test_archive_root(document: Document) -> None: + root = document["metadata"]["component"] + assert {key: value for key, value in root.items() if key != "components"} == { + "type": "application", + "bom-ref": "pkg:generic/app.pyz@1.0", + "name": "app.pyz", + "version": "1.0", + "purl": "pkg:generic/app.pyz@1.0", + "supplier": {"name": "Example Org", "url": []}, + "properties": [ + {"name": "extra:project", "value": "project"}, + {"name": "extra:table", "value": "loaded.json"}, + {"name": "app:vcs-commit", "value": _COMMIT}, + ], + } + + +def test_archive_loose_files_belong_to_the_archive(document: Document) -> None: + files = document["metadata"]["component"].get("components", []) + assert [file["name"] for file in files] == ["__main__.py", "loaded.json"] + + +def test_archive_project_distribution(document: Document) -> None: + app = document["components"][0] + assert [(child["type"], child["name"]) for child in app.get("components", [])] == [ + ("file", "app-1.0.dist-info/METADATA"), + ("file", "app/__init__.py"), + ("library", "pip"), + ] + + +def test_archive_bundled_wheel(document: Document) -> None: + pip = document["components"][0].get("components", [])[2] + assert (pip["bom-ref"], pip.get("supplier"), pip.get("properties", [])[0]) == ( + f"bundled:{_WHEEL}", + {"name": "Example Org", "url": []}, + {"name": "app:bundled-wheel", "value": _WHEEL}, + ) + + +def test_archive_unpacked_wheel(document: Document) -> None: + distlib = document["components"][1] + assert ( + distlib["bom-ref"], + "supplier" in distlib, + distlib.get("properties", []), + [child["name"] for child in distlib.get("components", [])], + ) == ( + f"bundled:{_LIBRARY}", + True, + [{"name": "app:bundled-wheel", "value": _LIBRARY}, {"name": "app:loaded-for", "value": "3.14"}], + [f"{_LIBRARY}/distlib-0.4.dist-info/METADATA", f"{_LIBRARY}/distlib/__init__.py"], + ) + + +def test_archive_tools_and_metadata(document: Document) -> None: + metadata = document["metadata"] + script = metadata["tools"]["components"][0] + assert (script["name"], script.get("externalReferences"), metadata.get("properties"), metadata.get("supplier")) == ( + "build_app.py", + [{"type": "vcs", "url": "https://github.com/example/app/blob/main/build_app.py"}], + [{"name": "app:sbom:file", "value": "app.pyz.cdx.json"}], + {"name": "Example Org", "url": []}, + ) + + +def test_archive_contributions(document: Document) -> None: + assert (document["components"][-1]["name"], document["dependencies"][-1]) == ( + "zlib", + {"ref": "pkg:generic/zlib@1.3", "dependsOn": []}, + ) + + +def test_archive_defaults(tmp_path: Path, archive: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'app'\n", encoding="utf-8") + document = describe_archive(tmp_path, archive, facts=collect_facts(tmp_path, revision_reader(None))) + assert ("supplier" in document["metadata"], document["metadata"]["component"].get("properties")) == (False, []) + + +def test_archive_content_provider_option(tmp_path: Path, archive: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + f"[tool.python-sbom]\ncontent-provider = '{tmp_path.name}:Provider'\n", encoding="utf-8" + ) + (tmp_path / f"{tmp_path.name}.py").write_text( + "from python_sbom import ContentProvider\n\n\nclass Provider(ContentProvider):\n pass\n", encoding="utf-8" + ) + tools = describe_archive(tmp_path, archive, facts=collect_facts(tmp_path, revision_reader(None)))["metadata"][ + "tools" + ]["components"] + assert tools[0]["name"] == f"{tmp_path.name}.py" + + +def test_archive_without_project_distribution(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as handle: + handle.writestr("__main__.py", "") + (archive := tmp_path / "bare.pyz").write_bytes(buffer.getvalue()) + with pytest.raises( + WheelError, match=r"bare\.pyz holds 0 top-level \.dist-info/METADATA, not the one naming the project" + ): + describe_archive(tmp_path, archive, facts=collect_facts(tmp_path, revision_reader(None))) diff --git a/tests/_generate/test_embed.py b/tests/_generate/test_embed.py new file mode 100644 index 0000000..1db1afa --- /dev/null +++ b/tests/_generate/test_embed.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import hashlib +import json +import platform +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from python_sbom import BundledWheelInfo, ContentProvider, ProviderContext +from python_sbom._generate._embed import embed_sbom + +if TYPE_CHECKING: + from pathlib import PurePosixPath + + import pytest + from conftest import Install, WriteWheel + +_PINNED: Final[Path] = Path(__file__).parent / "pinned" + + +def test_bundled_wheel_named_by_its_path_inside_the_wheel(tmp_path: Path, write_wheel: WriteWheel) -> None: + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_text("[tool.python-sbom]\nbundled-wheels = ['src/pkg/wheels/*.whl']\n") + bundled = write_wheel( + root / "src" / "pkg" / "wheels" / "pip-1.0-py3-none-any.whl", + {"pip/__init__.py": b"", "pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\nLicense-Expression: MIT\n"}, + ) + wheel = write_wheel( + tmp_path / "dist" / "pkg-1.0-py3-none-any.whl", + { + "pkg/wheels/pip-1.0-py3-none-any.whl": bundled.read_bytes(), + "pkg/wheels/table.txt": b"3.15\n", + "pkg-1.0.dist-info/METADATA": b"Name: pkg\nVersion: 1.0\n", + }, + ) + written = embed_sbom(wheel, root, content=Table("table.txt"), build_env=[str(root)]) + (pip,) = json.loads(zipfile.ZipFile(wheel).read(written))["components"] + assert (pip["bom-ref"], pip["properties"][:2]) == ( + "bundled:pkg/wheels/pip-1.0-py3-none-any.whl", + [ + {"name": "pkg:bundled-wheel", "value": "pkg/wheels/pip-1.0-py3-none-any.whl"}, + {"name": "pkg:table", "value": "3.15"}, + ], + ) + + +class Table(ContentProvider): + def __init__(self, name: str) -> None: + self._name = name + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + # the table sits next to the wheel inside the built wheel + value = context.read(wheel.parent / self._name).decode().strip() + return BundledWheelInfo(properties=[{"name": f"{context.identity.property_prefix}:table", "value": value}]) + + +def test_embed_output_is_pinned( + tmp_path: Path, write_wheel: WriteWheel, install: Install, monkeypatch: pytest.MonkeyPatch +) -> None: + # every input fixed, down to the interpreter's version, so the SBOM and the wheel's members must not change by a + # byte; a change that means to alter them updates the pinned files with it + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + monkeypatch.setattr(platform, "python_version", lambda: "3.14.0") + metadata = b"""\ +Metadata-Version: 2.4 +Name: Pkg.Name +Version: 1.0 +Summary: a pinned example +Keywords: sbom,pinned +Author: Author Name +Author-email: Author , plain@example.com +Maintainer-email: not-an-email +License-Expression: MIT OR Apache-2.0 +License-File: LICENSE +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Project-URL: Source, https://github.com/example/pkg +Project-URL: Source, https://gitlab.com/example/pkg +Project-URL: Documentation, https://pkg.readthedocs.io +Project-URL: Bug Tracker, https://github.com/example/pkg/issues +Requires-Dist: alpha>=1 +Requires-Dist: beta; extra == "cli" +Requires-Dist: gamma; python_version < "3" +Requires-Dist: delta; extra in "a,b" +Provides-Extra: cli +""" + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_bytes( + b"[tool.python-sbom]\nsupplier = { name = 'Example Org', url = ['https://example.org'] }\n" + b"bundled-wheels = ['src/pkg/wheels/*.whl']\n" + ) + pip = { + "pip/__init__.py": b"", + "pip/_vendor/vendor.txt": b"urllib3==1.26.4\nidna==3.7\n", + "pip-1.0.dist-info/METADATA": b"Metadata-Version: 2.4\nName: pip\nVersion: 1.0\nLicense-Expression: MIT\n", + } + # stored, as deflate output differs between zlib builds and the SBOM records the bundled wheel's hash + bundled = write_wheel(root / "src" / "pkg" / "wheels" / "pip-1.0-py3-none-any.whl", pip, compression=0) + site = tmp_path / "site-packages" + install(site, "Name: alpha\nVersion: 1.0\nRequires-Dist: beta\n", {"alpha.py": b"", "../../bin/alpha": b""}) + install(site, "Name: beta\nVersion: 2.0\n", {"beta.so": b"\x7fELF"}, purelib=False) + install(site, "Name: gamma\nVersion: 3.0\n", {}, direct_url='{"url": "file:///c", "dir_info": {"editable": true}}') + wheel = write_wheel( + tmp_path / "dist" / "pkg_name-1.0-py3-none-any.whl", + { + "pkg_name/__init__.py": b"x = 1\n", + "pkg_name/wheels/pip-1.0-py3-none-any.whl": bundled.read_bytes(), + "pkg_name-1.0.dist-info/METADATA": metadata, + "pkg_name-1.0.dist-info/licenses/LICENSE": b"MIT terms\n", + "pkg_name-1.0.dist-info/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + }, + compression=0, + ) + written = embed_sbom(wheel, root, content=None, commit="a" * 40, build_env=[str(site)]) + with zipfile.ZipFile(wheel) as archive: + sbom = archive.read(written) + members = [ + [info.filename, list(info.date_time), info.compress_type, info.external_attr, _sha256(archive.read(info))] + for info in archive.infolist() + ] + assert sbom == (_PINNED / "embed.cdx.json").read_bytes() + assert members == json.loads((_PINNED / "embed-members.json").read_bytes()) + + +def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() diff --git a/tests/_generate/test_pipeline.py b/tests/_generate/test_pipeline.py new file mode 100644 index 0000000..e65a885 --- /dev/null +++ b/tests/_generate/test_pipeline.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from python_sbom import ContentProvider +from python_sbom._formats._json import dumps +from python_sbom._generate._pipeline import build_sbom +from python_sbom._model._inputs import SbomRequest, SourceTree +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from conftest import ContextFor + + from python_sbom._model._inputs import BuildFacts, Settings + + +def test_sbom_file_name_and_content(settings: Settings, facts: BuildFacts, context_for: ContextFor) -> None: + request = SbomRequest( + root=Path(), + metadata=parse_metadata("Name: My.Project\nVersion: 1.0\n"), + wheel_name="my_project-1.0-py3-none-any.whl", + settings=settings, + build_scripts=(), + ) + context = context_for(Path(), Path()) + sbom = build_sbom( + request, SourceTree(copyright=None, bundled=[], build_scripts=[]), facts, ContentProvider(), context + ) + assert (sbom.file_name, sbom.content) == ("my-project.cdx.json", dumps(sbom.document)) diff --git a/tests/_model/test_bom.py b/tests/_model/test_bom.py new file mode 100644 index 0000000..7fc51dd --- /dev/null +++ b/tests/_model/test_bom.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import dataclasses +import json +import uuid +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom import BundledWheelInfo, ContentProvider, Contributions, ProviderContext +from python_sbom._model._inputs import BuildScript, BundledWheel, Revision, SourceTree +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from conftest import Describe + + from python_sbom import Component + from python_sbom._model._inputs import BuildFacts, Settings + from python_sbom._model._types import OrganizationalEntity + +_INTERPRETER: Final[str] = "tool:pkg:generic/cpython@3.15.0" + + +def test_minimal_document(describe: Describe) -> None: + document = describe() + body = {key: value for key, value in document.items() if key not in {"$schema", "bomFormat", "specVersion"}} + serial = body.pop("serialNumber") + del body["version"] + expected_body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": { + "components": [ + { + "type": "platform", + "bom-ref": _INTERPRETER, + "name": "cpython", + "version": "3.15.0", + "purl": "pkg:generic/cpython@3.15.0", + "properties": [{"name": "python:implementation", "value": "CPython"}], + } + ] + }, + "component": { + "type": "application", + "bom-ref": "pkg:pypi/example@1.0", + "name": "Example", + "version": "1.0", + "purl": "pkg:pypi/example@1.0", + "externalReferences": [ + {"type": "distribution", "url": "https://pypi.org/project/Example/1.0/"}, + { + "type": "attestation", + "url": "https://pypi.org/integrity/Example/1.0/example-1.0-py3-none-any.whl/provenance", + }, + ], + "properties": [], + }, + "properties": [ + { + "name": "example:sbom:scope", + "value": "Components are the direct runtime dependencies declared in the wheel's core metadata," + " which carry no version because the installer resolves them.", + }, + {"name": "example:sbom:file", "value": "example.cdx.json"}, + ], + }, + "components": [], + "dependencies": [{"ref": "pkg:pypi/example@1.0", "dependsOn": []}], + "compositions": [{"aggregate": "incomplete", "dependencies": ["pkg:pypi/example@1.0"]}], + "formulation": [ + { + "bom-ref": "formula:wheel-build", + "workflows": [ + { + "bom-ref": "workflow:wheel-build", + "uid": "wheel-build", + "name": "build the wheel and this SBOM", + "taskTypes": ["build"], + "resourceReferences": [{"ref": _INTERPRETER}], + "outputs": [{"type": "artifact", "resource": {"ref": "pkg:pypi/example@1.0"}}], + } + ], + } + ], + } + assert body == expected_body + namespace = uuid.uuid5(uuid.NAMESPACE_URL, "https://pypi.org/project/example/sboms") + assert serial == f"urn:uuid:{uuid.uuid5(namespace, json.dumps(expected_body, sort_keys=True))}" + + +def test_header(describe: Describe) -> None: + document = describe() + header = {key: document[key] for key in ("$schema", "bomFormat", "specVersion", "version")} + assert header == { + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + } + + +def test_serial_is_stable(describe: Describe) -> None: + assert describe()["serialNumber"] == describe()["serialNumber"] + + +def test_serial_follows_content(describe: Describe) -> None: + assert describe()["serialNumber"] != describe("Summary: other\n")["serialNumber"] + + +@pytest.mark.parametrize( + ("dirty", "trail"), + [ + pytest.param(False, ("exact source revision", []), id="clean"), + pytest.param( + True, + ("source revision, with uncommitted changes", [{"name": "example:vcs-dirty", "value": "true"}]), + id="dirty", + ), + ], +) +def test_root_component( + describe: Describe, settings: Settings, facts: BuildFacts, dirty: bool, trail: tuple[str, list[dict[str, str]]] +) -> None: + comment, dirty_properties = trail + supplier: OrganizationalEntity = {"name": "Example Org", "url": ["https://example.org"]} + document = describe( + "License-File: LICENSE\nProject-URL: Source, https://github.com/example/example/\n", + settings=dataclasses.replace(settings, supplier=supplier), + facts=dataclasses.replace(facts, revision=Revision(commit="c0ffee", dirty=dirty)), + source=SourceTree(copyright="Copyright (c) 2026 Example", bundled=[], build_scripts=[]), + ) + repository = "https://github.com/example/example" + assert document["metadata"]["component"] == { + "type": "application", + "bom-ref": "pkg:pypi/example@1.0", + "name": "Example", + "version": "1.0", + "purl": "pkg:pypi/example@1.0", + "supplier": supplier, + "copyright": "Copyright (c) 2026 Example", + "externalReferences": [ + {"type": "vcs", "url": f"{repository}/", "comment": "Project-URL: Source"}, + {"type": "distribution", "url": "https://pypi.org/project/Example/1.0/"}, + { + "type": "attestation", + "url": "https://pypi.org/integrity/Example/1.0/example-1.0-py3-none-any.whl/provenance", + }, + {"type": "security-contact", "url": f"{repository}/security/policy"}, + {"type": "advisories", "url": f"{repository}/security/advisories"}, + {"type": "license", "url": f"{repository}/blob/main/LICENSE"}, + {"type": "vcs", "url": f"{repository}/tree/c0ffee", "comment": comment}, + ], + "properties": [{"name": "example:vcs-commit", "value": "c0ffee"}, *dirty_properties], + } + + +def test_supplier_makes_and_supplies_the_document(describe: Describe, settings: Settings) -> None: + supplier: OrganizationalEntity = {"name": "Example Org", "url": []} + metadata = describe(settings=dataclasses.replace(settings, supplier=supplier))["metadata"] + assert metadata.get("manufacturer") == supplier + assert metadata.get("supplier") == supplier + + +def test_metadata_repeats_root_authors_and_licenses(describe: Describe) -> None: + metadata = describe("Author: Author\nLicense-Expression: MIT\n")["metadata"] + assert metadata.get("authors") == [{"name": "Author"}] + assert metadata.get("licenses") == [{"expression": "MIT", "acknowledgement": "declared"}] + + +def test_root_licenses_from_classifiers(describe: Describe) -> None: + document = describe("Classifier: License :: OSI Approved :: MIT License\n") + assert document["metadata"]["component"].get("licenses") == [ + {"license": {"name": "MIT License", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ] + + +def test_repository_setting_wins_over_project_urls(describe: Describe, settings: Settings) -> None: + document = describe( + "Project-URL: Source, https://github.com/example/example\n", + settings=dataclasses.replace(settings, repository="https://git.example.org/example"), + ) + assert {"name": "example:sbom:repository", "value": "https://git.example.org/example"} in document["metadata"][ + "properties" + ] + + +def test_no_security_links_outside_github(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, repository="https://git.example.org/example")) + types = {reference["type"] for reference in document["metadata"]["component"]["externalReferences"]} + assert types.isdisjoint({"security-contact", "advisories"}) + + +def test_no_license_link_without_repository(describe: Describe) -> None: + document = describe("License-File: LICENSE\n") + types = {reference["type"] for reference in document["metadata"]["component"]["externalReferences"]} + assert "license" not in types + + +def test_revision_without_repository_records_commit_only(describe: Describe, facts: BuildFacts) -> None: + document = describe(facts=dataclasses.replace(facts, revision=Revision(commit="c0ffee", dirty=False))) + root = document["metadata"]["component"] + assert root["properties"] == [{"name": "example:vcs-commit", "value": "c0ffee"}] + assert [reference["type"] for reference in root["externalReferences"]] == ["distribution", "attestation"] + + +def test_declared_dependency(describe: Describe) -> None: + requirement = 'tomli>=2; python_version < "3.11"' + document = describe(f"Requires-Dist: {requirement}\n") + assert document["components"] == [ + { + "type": "library", + "bom-ref": f"requires-dist:{requirement}", + "name": "tomli", + "purl": "pkg:pypi/tomli", + "externalReferences": [{"type": "distribution", "url": "https://pypi.org/project/tomli/"}], + "properties": [ + {"name": "example:requires-dist", "value": requirement}, + {"name": "example:environment-marker", "value": 'python_version < "3.11"'}, + ], + } + ] + + +def test_repeated_requirement_is_one_component(describe: Describe) -> None: + document = describe("Requires-Dist: packaging>=24\nRequires-Dist: packaging>=24\n") + assert [component["bom-ref"] for component in document["components"]] == ["requires-dist:packaging>=24"] + + +def test_scope_names_bundled_directories(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, bundled_wheels=("src/b/*.whl", "src/a/x/*.whl"))) + assert document["metadata"]["properties"][0]["value"] == ( + "Components are the wheels bundled as data files under src/a/x and src/b and the direct runtime dependencies" + " declared in the wheel's core metadata, which carry no version because the installer resolves them." + ) + + +def test_bundled_wheel_and_its_vendored_packages(describe: Describe) -> None: + wheel = BundledWheel( + path="src/pip-1.0-py3-none-any.whl", + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[("pip/_vendor/vendor.txt", parse_metadata("Name: urllib3\nVersion: 1.26.4\n"))], + ) + + class Seeded(ContentProvider): + def __init__(self, name: str) -> None: + self._name = name + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + return BundledWheelInfo( + properties=[{"name": f"{context.identity.property_prefix}:{self._name}", "value": str(wheel)}] + ) + + document = describe(source=SourceTree(copyright=None, bundled=[wheel], build_scripts=[]), content=Seeded("seeded")) + assert document["components"][0]["properties"][1] == { + "name": "example:seeded", + "value": "src/pip-1.0-py3-none-any.whl", + } + bundled_ref = "bundled:src/pip-1.0-py3-none-any.whl" + assert document["dependencies"][:2] == [ + {"ref": "pkg:pypi/example@1.0", "dependsOn": [bundled_ref]}, + {"ref": bundled_ref, "dependsOn": [f"{bundled_ref}#vendored/pkg:pypi/urllib3@1.26.4"]}, + ] + + +def test_build_script_is_a_linked_tool(describe: Describe, settings: Settings) -> None: + source = SourceTree( + copyright=None, + bundled=[], + build_scripts=[BuildScript(path=PurePosixPath("hatch_build.py"), sha256="ef" * 32)], + ) + document = describe( + settings=dataclasses.replace(settings, repository="https://github.com/example/example"), source=source + ) + assert document["metadata"]["tools"]["components"][0] == { + "type": "application", + "bom-ref": "tool:hatch_build.py", + "name": "hatch_build.py", + "version": "1.0", + "hashes": [{"alg": "SHA-256", "content": "ef" * 32}], + "externalReferences": [{"type": "vcs", "url": "https://github.com/example/example/blob/main/hatch_build.py"}], + } + + +@pytest.mark.parametrize( + ("from_environment", "inputs"), + [ + pytest.param(False, None, id="unset"), + pytest.param( + True, [{"environmentVars": [{"name": "SOURCE_DATE_EPOCH", "value": "1700000000"}]}], id="from-environment" + ), + ], +) +def test_formulation_names_source_date_epoch_only_when_set( + describe: Describe, facts: BuildFacts, from_environment: bool, inputs: list[dict[str, list[dict[str, str]]]] | None +) -> None: + document = describe(facts=dataclasses.replace(facts, epoch_from_environment=from_environment)) + assert document.get("formulation", [])[0]["workflows"][0].get("inputs") == inputs + + +def test_contributions_join_before_references_and_serial(describe: Describe) -> None: + library: Component = {"type": "library", "bom-ref": "pkg:generic/zlib@1.3", "name": "zlib", "version": "1.3"} + tool: Component = {"type": "application", "bom-ref": "tool:pkg:generic/cmake@4", "name": "cmake", "version": "4"} + + class Native(ContentProvider): + def __init__(self, components: list[Component]) -> None: + self._components = components + + def contributions(self, context: ProviderContext) -> Contributions: + return Contributions( + components=self._components, + dependencies=[{"ref": component["bom-ref"], "dependsOn": []} for component in self._components], + root_properties=[{"name": "native:prefix", "value": context.identity.property_prefix}], + tools=[tool], + ) + + native = describe(content=Native([library])) + assert ( + native["components"], + native["dependencies"], + native["metadata"]["component"]["properties"], + native["metadata"]["tools"]["components"][-1], + native.get("formulation", [])[0]["workflows"][0]["resourceReferences"], + ) == ( + [library], + [ + {"ref": "pkg:pypi/example@1.0", "dependsOn": ["pkg:generic/zlib@1.3"]}, + {"ref": "pkg:generic/zlib@1.3", "dependsOn": []}, + ], + [{"name": "native:prefix", "value": "example"}], + tool, + [{"ref": _INTERPRETER}, {"ref": "tool:pkg:generic/cmake@4"}], + ) + assert native["serialNumber"] != describe()["serialNumber"] + + +_ORG: Final[OrganizationalEntity] = {"name": "Example Org", "url": []} + + +@pytest.mark.parametrize( + ("supplied", "expected"), + [pytest.param(True, _ORG, id="by-project"), pytest.param(False, None, id="unnamed")], +) +def test_bundled_wheel_supplier_is_the_projects_choice( + describe: Describe, settings: Settings, supplied: bool, expected: OrganizationalEntity | None +) -> None: + wheel = BundledWheel( + path="src/pip-1.0-py3-none-any.whl", + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\nProject-URL: Source, https://github.com/example/pip\n"), + files=[], + vendored=[], + ) + + class Supplied(ContentProvider): + def __init__(self, supplied: bool) -> None: + self._supplied = supplied + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: # ruff: ignore[unused-method-argument] # the base class's signature + return BundledWheelInfo(supplier=context.settings.supplier if self._supplied else None) + + document = describe( + settings=dataclasses.replace(settings, supplier=_ORG), + source=SourceTree(copyright=None, bundled=[wheel], build_scripts=[]), + content=Supplied(supplied), + ) + assert document["components"][0].get("supplier") == expected diff --git a/tests/_model/test_components.py b/tests/_model/test_components.py new file mode 100644 index 0000000..ba274b8 --- /dev/null +++ b/tests/_model/test_components.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import hashlib +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._model._components import ( + bundled_wheel_component, + component_from_metadata, + file_component_from_bytes, + script_tool, +) +from python_sbom._model._inputs import BundledWheel, RecordRow +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from python_sbom import Component, Property + from python_sbom._model._types import License, OrganizationalEntity + +_WHEEL: Final[str] = "src/example/wheels/pip-1.0-py3-none-any.whl" + + +def test_component_from_metadata() -> None: + metadata = parse_metadata( + "Name: Example\nVersion: 1.0\nSummary: an example\nMaintainer-email: Maintainer \n" + "License-Expression: MIT\nRequires-Python: >=3.11\nRequires-Dist: packaging>=24\nProvides-Extra: cli\n" + "Classifier: Programming Language :: Python :: 3\nKeywords: sample,demo\n" + "Project-URL: Source, https://github.com/example/example\n" + ) + assert component_from_metadata(metadata) == { + "type": "library", + "bom-ref": "pkg:pypi/example@1.0", + "name": "Example", + "version": "1.0", + "purl": "pkg:pypi/example@1.0", + "description": "an example", + "authors": [{"name": "Maintainer", "email": "maintainer@example.com"}], + "licenses": [{"expression": "MIT", "acknowledgement": "declared"}], + "externalReferences": [ + {"type": "vcs", "url": "https://github.com/example/example", "comment": "Project-URL: Source"} + ], + "properties": [ + {"name": "python:requires-python", "value": ">=3.11"}, + {"name": "python:requires-dist", "value": "packaging>=24"}, + {"name": "python:provides-extra", "value": "cli"}, + {"name": "python:classifier", "value": "Programming Language :: Python :: 3"}, + {"name": "python:keywords", "value": "sample,demo"}, + ], + } + + +@pytest.mark.parametrize( + ("fields", "expected"), + [ + pytest.param("Author: Author\nMaintainer: Maintainer\n", [{"name": "Maintainer"}], id="maintainer-wins"), + pytest.param( + "Author-email: Author , other@example.com\n", + [{"name": "Author", "email": "author@example.com"}, {"email": "other@example.com"}], + id="authors-without-maintainer", + ), + pytest.param( + "Author-email: Jane Doe \n", [{"name": "Jane Doe jane at example.com"}], id="bad-email" + ), + ], +) +def test_component_authors(fields: str, expected: list[dict[str, str]]) -> None: + assert component_from_metadata(parse_metadata(f"Name: a\nVersion: 1\n{fields}"))["authors"] == expected + + +def test_component_without_authors_has_no_key() -> None: + assert "authors" not in component_from_metadata(parse_metadata("Name: a\nVersion: 1\n")) + + +@pytest.mark.parametrize( + ("fields", "expected"), + [ + pytest.param( + "Classifier: License :: OSI Approved :: MIT License\n", + [ + {"license": {"name": "MIT License", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ], + id="classifier", + ), + pytest.param( + "License: apache-2.0\n", + [ + {"license": {"name": "apache-2.0", "acknowledgement": "declared"}}, + {"license": {"id": "Apache-2.0", "acknowledgement": "concluded"}}, + ], + id="license-field", + ), + pytest.param( + "Classifier: License :: OSI Approved :: MIT License\nLicense: MIT\n", + [ + {"license": {"name": "MIT License", "acknowledgement": "declared"}}, + {"license": {"name": "MIT", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ], + id="same-id-once", + ), + pytest.param( + "Classifier: License :: OSI Approved :: BSD License\nLicense: MIT\n", + [ + {"license": {"name": "BSD License", "acknowledgement": "declared"}}, + {"license": {"name": "MIT", "acknowledgement": "declared"}}, + ], + id="one-unresolved-name-concludes-nothing", + ), + pytest.param( + "License: LicenseRef-Proprietary\n", + [{"license": {"name": "LicenseRef-Proprietary", "acknowledgement": "declared"}}], + id="license-ref", + ), + pytest.param( + "License: MIT OR Apache-2.0\n", + [{"license": {"name": "MIT OR Apache-2.0", "acknowledgement": "declared"}}], + id="expression-in-license-field", + ), + pytest.param( + "License: not a license\n", + [{"license": {"name": "not a license", "acknowledgement": "declared"}}], + id="invalid-expression", + ), + ], +) +def test_component_licenses(fields: str, expected: list[License]) -> None: + assert component_from_metadata(parse_metadata(f"Name: a\nVersion: 1\n{fields}"))["licenses"] == expected + + +def test_component_ignores_license_text() -> None: + metadata = parse_metadata("Name: a\nVersion: 1\nLicense: Copyright\n the full text\n") + assert "licenses" not in component_from_metadata(metadata) + + +@pytest.mark.parametrize( + ("fields", "expected"), + [ + pytest.param("Home-page: UNKNOWN\n", [], id="unknown-home-page"), + pytest.param( + "Home-page: https://example.com\n", [{"type": "website", "url": "https://example.com"}], id="home-page" + ), + pytest.param("Project-URL: no comma\n", [], id="url-without-label"), + pytest.param( + "Project-URL: Bug Tracker, https://example.com/issues\n", + [{"type": "issue-tracker", "url": "https://example.com/issues", "comment": "Project-URL: Bug Tracker"}], + id="normalized-label", + ), + pytest.param( + "Project-URL: Source Code, https://example.com/x\n", + [{"type": "vcs", "url": "https://example.com/x", "comment": "Project-URL: Source Code"}], + id="pep-753-source-code", + ), + pytest.param( + "Project-URL: Bugs, https://example.com/x\n", + [{"type": "issue-tracker", "url": "https://example.com/x", "comment": "Project-URL: Bugs"}], + id="pep-753-bugs", + ), + pytest.param( + "Project-URL: Issue, https://example.com/x\n", + [{"type": "issue-tracker", "url": "https://example.com/x", "comment": "Project-URL: Issue"}], + id="pep-753-issue", + ), + pytest.param( + "Project-URL: Release Notes, https://example.com/x\n", + [{"type": "release-notes", "url": "https://example.com/x", "comment": "Project-URL: Release Notes"}], + id="pep-753-release-notes", + ), + pytest.param( + "Project-URL: What's New, https://example.com/x\n", + [{"type": "release-notes", "url": "https://example.com/x", "comment": "Project-URL: What's New"}], + id="pep-753-whats-new", + ), + pytest.param( + "Project-URL: History, https://example.com/x\n", + [{"type": "release-notes", "url": "https://example.com/x", "comment": "Project-URL: History"}], + id="pep-753-history", + ), + pytest.param( + "Project-URL: Funding, https://example.com/fund\n", + [{"type": "other", "url": "https://example.com/fund", "comment": "Project-URL: Funding"}], + id="unknown-label", + ), + ], +) +def test_component_references(fields: str, expected: list[dict[str, str]]) -> None: + component = component_from_metadata(parse_metadata(f"Name: a\nVersion: 1\n{fields}")) + assert component["externalReferences"] == expected + + +def test_bundled_wheel_component() -> None: + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[RecordRow(path="pip/__init__.py", algorithm="SHA-256", digest="cd" * 32, size="0")], + vendored=[], + ) + supplier: OrganizationalEntity = {"name": "PyPA", "url": ["https://www.pypa.io"]} + properties: list[Property] = [{"name": "example:seeded-for-python", "value": "3.15"}] + assert bundled_wheel_component(wheel, "example", properties, supplier) == { + "type": "library", + "bom-ref": f"bundled:{_WHEEL}", + "name": "pip", + "version": "1.0", + "purl": "pkg:pypi/pip@1.0", + "supplier": supplier, + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "externalReferences": [{"type": "distribution", "url": "https://pypi.org/project/pip/1.0/"}], + "properties": [ + {"name": "example:bundled-wheel", "value": _WHEEL}, + {"name": "example:seeded-for-python", "value": "3.15"}, + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + {"technique": "manifest-analysis", "confidence": 1, "value": "pip-1.0.dist-info/METADATA"} + ], + }, + { + "field": "hash", + "confidence": 1, + "methods": [{"technique": "hash-comparison", "confidence": 1, "value": "ab" * 32}], + }, + ] + }, + "components": [ + { + "type": "file", + "bom-ref": f"bundled:{_WHEEL}#pip/__init__.py", + "name": "pip/__init__.py", + "hashes": [{"alg": "SHA-256", "content": "cd" * 32}], + "properties": [{"name": "size", "value": "0"}], + } + ], + } + + +def test_bundled_wheel_vendored_metadata_wins_over_manifest() -> None: + manifest = "pip/_vendor/vendor.txt" + dist_info = "pip/_vendor/urllib3-1.26.4.dist-info/METADATA" + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[ + (manifest, parse_metadata("Name: urllib3\nVersion: 1.26.4\n")), + (dist_info, parse_metadata("Name: urllib3\nVersion: 1.26.4\nLicense-Expression: MIT\n")), + ], + ) + component = bundled_wheel_component(wheel, "example", [], None) + assert component["components"] == [ + { + "type": "library", + "bom-ref": f"bundled:{_WHEEL}#vendored/pkg:pypi/urllib3@1.26.4", + "name": "urllib3", + "version": "1.26.4", + "purl": "pkg:pypi/urllib3@1.26.4", + "licenses": [{"expression": "MIT", "acknowledgement": "declared"}], + "externalReferences": [], + "properties": [{"name": "example:vendored-manifest", "value": dist_info}], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [{"technique": "manifest-analysis", "confidence": 1, "value": dist_info}], + } + ] + }, + } + ] + + +def test_bundled_wheel_component_ref_tells_copies_apart() -> None: + def component(path: str) -> Component: + wheel = BundledWheel( + path=path, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[], + ) + return bundled_wheel_component(wheel, "example", [], None) + + refs = {component(f"src/{directory}/pip-1.0-py3-none-any.whl")["bom-ref"] for directory in ("a", "b")} + assert refs == {"bundled:src/a/pip-1.0-py3-none-any.whl", "bundled:src/b/pip-1.0-py3-none-any.whl"} + + +def test_bundled_wheel_supplier_never_comes_from_the_wheel() -> None: + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\nProject-URL: Source, https://github.com/example/pip\n"), + files=[], + vendored=[], + ) + component = bundled_wheel_component(wheel, "example", [], None) + assert "supplier" not in component + + +def test_file_component_from_bytes() -> None: + assert file_component_from_bytes("app", "a.py", b"abc") == { + "type": "file", + "bom-ref": "app#a.py", + "name": "a.py", + "hashes": [{"alg": "SHA-256", "content": hashlib.sha256(b"abc").hexdigest()}], + "properties": [{"name": "size", "value": "3"}], + } + + +def test_script_tool_with_link() -> None: + tool = script_tool(PurePosixPath("build/hook.py"), "ef" * 32, "1.0", "https://example.com/hook.py") + assert tool == { + "type": "application", + "bom-ref": "tool:build/hook.py", + "name": "hook.py", + "version": "1.0", + "hashes": [{"alg": "SHA-256", "content": "ef" * 32}], + "externalReferences": [{"type": "vcs", "url": "https://example.com/hook.py"}], + } + + +def test_script_tool_without_link() -> None: + assert "externalReferences" not in script_tool(PurePosixPath("hook.py"), "ef" * 32, "1.0", None) + + +def test_vendored_spellings_of_one_package_merge() -> None: + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[ + ("pip/_vendor/vendor.txt", parse_metadata("Name: jaraco.text\nVersion: 4.0\n")), + ( + "pip/_vendor/jaraco_text-4.0.dist-info/METADATA", + parse_metadata("Name: jaraco_text\nVersion: 4.0\n"), + ), + ], + ) + component = bundled_wheel_component(wheel, "example", [], None) + assert [child["name"] for child in component.get("components", [])] == ["jaraco_text"] + + +def test_pypi_purl_keeps_dots() -> None: + component = component_from_metadata(parse_metadata("Name: zope.interface\nVersion: 7.0\n")) + assert component["purl"] == "pkg:pypi/zope.interface@7.0" diff --git a/tests/_model/test_content.py b/tests/_model/test_content.py new file mode 100644 index 0000000..0a969a0 --- /dev/null +++ b/tests/_model/test_content.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +import pytest + +from python_sbom import BundledWheelInfo, ContentProvider, Contributions + +if TYPE_CHECKING: + from conftest import ContextFor + + +def test_default_provider_adds_nothing_to_bundled_wheels(context_for: ContextFor) -> None: + info = ContentProvider().bundled_wheel(context_for(Path(), Path()), PurePosixPath("a.whl")) + assert info == BundledWheelInfo() + + +def test_default_provider_contributes_nothing(context_for: ContextFor) -> None: + assert ContentProvider().contributions(context_for(Path(), Path())) == Contributions() + + +def test_context_carries_project(context_for: ContextFor, tmp_path: Path) -> None: + context = context_for(tmp_path, tmp_path) + assert (context.root, context.identity.property_prefix, context.settings.default_branch) == ( + tmp_path, + "example", + "main", + ) + + +def test_context_reads_a_directory(tmp_path: Path, context_for: ContextFor) -> None: + (tmp_path / "embed").mkdir() + (tmp_path / "embed" / "table.py").write_bytes(b"x = 1\n") + assert context_for(tmp_path, tmp_path).read(PurePosixPath("embed/table.py")) == b"x = 1\n" + + +def test_context_reads_a_zip(tmp_path: Path, context_for: ContextFor) -> None: + with zipfile.ZipFile(wheel := tmp_path / "a.whl", "w") as archive: + archive.writestr("embed/table.py", "x = 1\n") + assert context_for(tmp_path, wheel).read(PurePosixPath("embed/table.py")) == b"x = 1\n" + + +@pytest.mark.parametrize("artifact", [pytest.param("dir", id="directory"), pytest.param("a.whl", id="zip")]) +def test_context_misses(tmp_path: Path, context_for: ContextFor, artifact: str) -> None: + (tmp_path / "dir").mkdir() + with zipfile.ZipFile(tmp_path / "a.whl", "w") as archive: + archive.writestr("other.py", "") + with pytest.raises(KeyError): + context_for(tmp_path, tmp_path / artifact).read(PurePosixPath("missing.py")) diff --git a/tests/_model/test_document.py b/tests/_model/test_document.py new file mode 100644 index 0000000..6cf9626 --- /dev/null +++ b/tests/_model/test_document.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import uuid +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._model._document import contributed_properties, with_header + +if TYPE_CHECKING: + from python_sbom import Property + from python_sbom._model._types import Body + + +def test_document_serial_derives_from_body() -> None: + body: Body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": {"components": []}, + "component": {"type": "application", "bom-ref": "app", "name": "app"}, + }, + "components": [], + "dependencies": [], + } + namespace = uuid.uuid5(uuid.NAMESPACE_URL, "https://example.org/sboms") + assert with_header(body, namespace) == { + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{uuid.uuid5(namespace, json.dumps(body, sort_keys=True))}", + "version": 1, + **body, + } + + +def test_header_refuses_repeated_refs() -> None: + body: Body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": {"components": [{"type": "application", "bom-ref": "app", "name": "tool"}]}, + "component": {"type": "application", "bom-ref": "app", "name": "app"}, + }, + "components": [], + "dependencies": [], + } + with pytest.raises(SbomError, match="bom-ref values repeat: app"): + with_header(body, uuid.uuid4()) + + +def test_header_refuses_unknown_dependency() -> None: + body: Body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": {"components": []}, + "component": {"type": "application", "bom-ref": "app", "name": "app"}, + }, + "components": [], + "dependencies": [{"ref": "app", "dependsOn": ["ghost"]}], + } + with pytest.raises(SbomError, match="dependencies name bom-refs no component has: ghost"): + with_header(body, uuid.uuid4()) + + +def test_contributed_properties_keep_their_own_prefix() -> None: + properties: list[Property] = [{"name": "extra:key", "value": "1"}] + assert contributed_properties(properties, "example") == properties + + +def test_contributed_properties_refuse_the_project_prefix() -> None: + with pytest.raises(SbomError, match="example:vcs-commit use the example: prefix python-sbom writes"): + contributed_properties([{"name": "example:vcs-commit", "value": "0"}], "example") diff --git a/tests/_model/test_identity.py b/tests/_model/test_identity.py new file mode 100644 index 0000000..0a14415 --- /dev/null +++ b/tests/_model/test_identity.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import dataclasses +import uuid +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._model._identity import ProjectIdentity, project_identity +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from python_sbom._model._inputs import Settings + + +@pytest.mark.parametrize( + ("repository", "urls", "expected"), + [ + pytest.param(None, "", None, id="none"), + pytest.param( + None, + "Project-URL: Docs, https://example.org\nProject-URL: Source, https://github.com/example/example/\n", + "https://github.com/example/example", + id="first-vcs-project-url", + ), + pytest.param( + "https://git.example.org/example", + "Project-URL: Source, https://github.com/example/example\n", + "https://git.example.org/example", + id="setting-wins", + ), + ], +) +def test_project_identity_repository( + settings: Settings, repository: str | None, urls: str, expected: str | None +) -> None: + metadata = parse_metadata(f"Name: My_Project\nVersion: 1.0\n{urls}") + identity = project_identity(metadata, dataclasses.replace(settings, repository=repository)) + assert identity == ProjectIdentity( + property_prefix="my-project", + repository=expected, + serial_namespace=uuid.uuid5(uuid.NAMESPACE_URL, f"{expected or 'https://pypi.org/project/my-project'}/sboms"), + ) diff --git a/tests/_model/test_licenses.py b/tests/_model/test_licenses.py new file mode 100644 index 0000000..fdc2c17 --- /dev/null +++ b/tests/_model/test_licenses.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._model._licenses import licenses +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from python_sbom import License + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + pytest.param( + "mit or apache-2.0", [{"expression": "MIT OR Apache-2.0", "acknowledgement": "declared"}], id="canonical" + ), + pytest.param( + "MIT WITH GPL-3.0-only", + [{"license": {"name": "MIT WITH GPL-3.0-only", "acknowledgement": "declared"}}], + id="invalid-kept-as-name", + ), + ], +) +def test_license_expression(expression: str, expected: list[License]) -> None: + assert licenses(parse_metadata(f"License-Expression: {expression}\n")) == expected diff --git a/tests/_model/test_markers.py b/tests/_model/test_markers.py new file mode 100644 index 0000000..5456cea --- /dev/null +++ b/tests/_model/test_markers.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import subprocess +import sys + +import pytest +from packaging.requirements import Requirement + +from python_sbom._model._markers import applies_without_extras + + +@pytest.mark.parametrize( + ("requirement", "applies"), + [ + pytest.param('click; extra == "cli"', False, id="extra"), + pytest.param('click; "cli" == extra', False, id="extra-on-the-right"), + pytest.param('click; extra != "cli"', True, id="not-extra"), + pytest.param('click; python_version < "3.11"', True, id="environment"), + pytest.param('click; extra == "cli" or python_version < "3.11"', True, id="extra-or-environment"), + pytest.param('click; extra == "cli" and python_version < "3.11"', False, id="extra-and-environment"), + pytest.param('click; (extra == "a" or extra == "b") and os_name == "nt"', False, id="grouped-extras"), + pytest.param( + 'click; python_version < "3.11" and (extra == "a" or os_name == "nt")', True, id="grouped-unknown" + ), + pytest.param('click; python_version >= "3" or extra == "a"', True, id="environment-or-extra"), + pytest.param("click", True, id="no-marker"), + pytest.param('click; extra == "A_b" and extra != "a-b"', False, id="extra-names-normalized"), + # only the conditions on extra decide, so a marker no environment satisfies still leaves the requirement listed + pytest.param('click; python_version < "3" and python_version >= "3"', True, id="environment-contradiction"), + pytest.param('click; (os_name == "nt" and os_name != "nt") or extra == "a"', True, id="contradiction-or-extra"), + pytest.param('click; extra in "a,b"', True, id="extra-in"), + pytest.param('click; extra not in "a,b"', False, id="extra-not-in"), + # extra compared as a version has no answer, so it stays unknown + pytest.param('click; extra ~= "1.0" and os_name == "nt"', True, id="extra-compatible-release"), + ], +) +def test_applies_without_extras(requirement: str, applies: bool) -> None: + assert applies_without_extras(Requirement(requirement)) is applies + + +def test_verdicts_hold_under_python_optimize() -> None: + # python -O strips assert statements, through which dep-logic turned these comparisons on extra away and so lost + # requirements that apply; the verdicts here must not depend on the mode + markers = { + 'extra in "a,b" and os_name == "nt"': True, + 'extra not in "a,b" or python_version < "3.11"': True, + 'extra < "b" and sys_platform == "linux"': False, + '"a" in extra or os_name == "nt"': True, + 'extra not in "" and python_version >= "3"': False, + '(extra >= "a" or extra == "cli") and os_name != "nt"': False, + } + script = ( + "import sys\n" + "from packaging.requirements import Requirement\n" + "from python_sbom._model._markers import applies_without_extras\n" + "print(*(applies_without_extras(Requirement(f'click; {marker}')) for marker in sys.argv[1:]))\n" + ) + result = subprocess.run([sys.executable, "-O", "-c", script, *markers], capture_output=True, text=True, check=True) + assert result.stdout.split() == [str(verdict) for verdict in markers.values()] diff --git a/tests/_model/test_metadata.py b/tests/_model/test_metadata.py new file mode 100644 index 0000000..e4e9fb1 --- /dev/null +++ b/tests/_model/test_metadata.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import pytest +from packaging.metadata import Metadata + +from python_sbom._model._components import component_from_metadata +from python_sbom._model._metadata import parse_metadata + + +def test_repeated_single_use_field_keeps_every_value() -> None: + # packaging moves an Author given twice out of raw, and unparsed keeps both as written + metadata = parse_metadata("Name: a\nVersion: 1\nAuthor: Ann\nAuthor: Bob\n") + assert (metadata.values("author"), metadata.first("author")) == (["Ann", "Bob"], "Ann") + + +def test_absent_field() -> None: + metadata = parse_metadata("Name: a\nVersion: 1\n") + assert (metadata.values("classifiers"), metadata.first("summary")) == ([], None) + + +def test_without_name_and_version() -> None: + metadata = parse_metadata("Summary: nameless\n") + assert (metadata.name, metadata.version) == ("", "") + + +@pytest.mark.parametrize( + ("fields", "keywords"), + [ + pytest.param("Keywords: a,b\n", ["a,b"], id="comma-separated"), + pytest.param("Keywords: a b\n", ["a b"], id="space-separated"), + # packaging splits at commas and strips each keyword, so the spacing around them is not kept + pytest.param("Keywords: a, b\n", ["a,b"], id="comma-and-space"), + pytest.param("Keywords: a\nKeywords: b\n", ["a", "b"], id="repeated"), + pytest.param("", [], id="absent"), + ], +) +def test_keywords(fields: str, keywords: list[str]) -> None: + assert parse_metadata(f"Name: a\nVersion: 1\n{fields}").keywords() == keywords + + +@pytest.mark.parametrize( + ("fields", "urls"), + [ + pytest.param( + "Project-URL: Source, https://a\nProject-URL: Docs, https://d\n", + [("Source", "https://a"), ("Docs", "https://d")], + id="distinct-labels", + ), + # packaging moves every Project-URL to unparsed when a label repeats, and none of them is lost + pytest.param( + "Project-URL: Source, https://a\nProject-URL: Source , https://b\nProject-URL: Docs, https://d\n", + [("Source", "https://a"), ("Source", "https://b"), ("Docs", "https://d")], + id="repeated-label", + ), + pytest.param("Project-URL: Source\nProject-URL: Docs, https://d\n", [("Docs", "https://d")], id="without-url"), + pytest.param( + "Project-URL: Source, https://a\nProject-URL: Source, https://b\nProject-URL: Bare\n", + [("Source", "https://a"), ("Source", "https://b")], + id="repeated-label-and-without-url", + ), + ], +) +def test_project_urls(fields: str, urls: list[tuple[str, str]]) -> None: + assert parse_metadata(f"Name: a\nVersion: 1\n{fields}").project_urls() == urls + + +# the headers of installed distributions that packaging's validating parser rejects, which the SBOM still describes +@pytest.mark.parametrize( + ("text", "described"), + [ + pytest.param( + "Metadata-Version: 2.1\nName: six\nVersion: 1.17.0\nLicense: MIT\n" + "Classifier: License :: OSI Approved :: MIT License\nLicense-File: LICENSE\n", + ("six", "1.17.0", ["MIT License", "MIT"]), + id="six-license-file-before-2.4", + ), + pytest.param( + "Metadata-Version: 2.1\nName: covdefaults\nVersion: 2.3.0\nLicense: MIT\n" + "Description-Content-Type: text/markdown\nLicense-File: LICENSE\nRequires-Dist: coverage (>=6.0.2)\n", + ("covdefaults", "2.3.0", ["MIT"]), + id="covdefaults-license-file-before-2.4", + ), + pytest.param( + "Metadata-Version: 2.0\nName: ply\nVersion: 3.11\nLicense: BSD\nDescription-Content-Type: UNKNOWN\n" + "Platform: UNKNOWN\n", + ("ply", "3.11", ["BSD"]), + id="ply-unknown-version-and-content-type", + ), + pytest.param( + "Metadata-Version: 2.1\nName: uri-template\nVersion: 1.3.0\nLicense: MIT License\n" + "Project-URL: homepage, https://gitlab.linss.com/open-source/python/uri-template\nKeywords: config\n" + "License-File: LICENSE\nProvides-Extra: dev\nRequires-Dist: mypy ; extra == 'dev'\n", + ("uri-template", "1.3.0", ["MIT License"]), + id="uri-template-license-file-before-2.4", + ), + ], +) +def test_metadata_the_validating_parser_rejects(text: str, described: tuple[str, str, list[str]]) -> None: + with pytest.raises(ExceptionGroup): + Metadata.from_email(text, validate=True) + component = component_from_metadata(parse_metadata(text)) + names = [entry["license"]["name"] for entry in component["licenses"] if "name" in entry["license"]] + assert (component["name"], component["version"], names) == described diff --git a/tests/_model/test_tools.py b/tests/_model/test_tools.py new file mode 100644 index 0000000..a7c671e --- /dev/null +++ b/tests/_model/test_tools.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING + +from python_sbom._model._inputs import InstalledDistribution, RecordRow +from python_sbom._model._metadata import parse_metadata +from python_sbom._model._tools import tool_components + +if TYPE_CHECKING: + from python_sbom._model._inputs import BuildFacts + + +def test_tools_list_interpreter_then_distributions_by_name(facts: BuildFacts) -> None: + row = RecordRow(path="zeta/__init__.py", algorithm="SHA-256", digest="ab" * 32, size="0") + distributions = [ + InstalledDistribution( + name="zeta", metadata=parse_metadata("Name: Zeta\nVersion: 2\n"), files=[row], depends_on=[] + ), + InstalledDistribution( + name="alpha", metadata=parse_metadata("Name: alpha\nVersion: 1\n"), files=[], depends_on=["zeta"] + ), + ] + tools, dependencies = tool_components(dataclasses.replace(facts, distributions=distributions)) + assert tools[1:] == [ + { + "type": "library", + "bom-ref": "tool:pkg:pypi/alpha@1", + "name": "alpha", + "version": "1", + "purl": "pkg:pypi/alpha@1", + "externalReferences": [], + "properties": [], + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/zeta@2", + "name": "Zeta", + "version": "2", + "purl": "pkg:pypi/zeta@2", + "externalReferences": [], + "properties": [], + "components": [ + { + "type": "file", + "bom-ref": "tool:pkg:pypi/zeta@2#zeta/__init__.py", + "name": "zeta/__init__.py", + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "properties": [{"name": "size", "value": "0"}], + } + ], + }, + ] + assert dependencies == [ + {"ref": "tool:pkg:pypi/alpha@1", "dependsOn": ["tool:pkg:pypi/zeta@2"]}, + {"ref": "tool:pkg:pypi/zeta@2", "dependsOn": []}, + ] diff --git a/tests/_seeder/LICENSE b/tests/_seeder/LICENSE new file mode 100644 index 0000000..b69c8d9 --- /dev/null +++ b/tests/_seeder/LICENSE @@ -0,0 +1,3 @@ +Copyright (c) 2026 Example + +MIT terms diff --git a/tests/_seeder/hatch_build.py b/tests/_seeder/hatch_build.py new file mode 100644 index 0000000..4475a61 --- /dev/null +++ b/tests/_seeder/hatch_build.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING + +from python_sbom import BundledWheelInfo, ContentProvider +from python_sbom.hatch import SbomBuildHook + +if TYPE_CHECKING: + from pathlib import PurePosixPath + + from python_sbom import ProviderContext + + +def get_build_hook() -> type[SeedSbomHook]: + # the module also sees the base class, so hatchling needs to be told which subclass to load + return SeedSbomHook + + +class SeedSbomHook(SbomBuildHook): + def content_provider(self) -> ContentProvider: # ruff: ignore[no-self-use] # overrides the hook's factory + return BundleSupport("BUNDLE_SUPPORT") + + +class BundleSupport(ContentProvider): + def __init__(self, table: str) -> None: + self._table = table + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + # the table next to the wheels decides which Python versions receive each one; it is read by name, without + # importing the package + module = ast.parse(context.read(wheel.parent / "__init__.py").decode("utf-8")) + support: dict[str, dict[str, str]] = ast.literal_eval( + next( + node.value + for node in module.body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == self._table for target in node.targets) + ) + ) + return BundledWheelInfo( + properties=[ + {"name": f"{context.identity.property_prefix}:seeded-for-python", "value": python} + for python, wheels in support.items() + if wheel.name in wheels.values() + ], + # the seeder bundles only its own supplier's wheels + supplier=context.settings.supplier, + ) diff --git a/tests/_seeder/pyproject.toml b/tests/_seeder/pyproject.toml new file mode 100644 index 0000000..7289986 --- /dev/null +++ b/tests/_seeder/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ "hatchling>=1.28", "python-sbom" ] + +[project] +name = "seeder" +version = "1.0" +description = "Seeds new environments with the wheels it bundles" +license = "MIT" +license-files = [ "LICENSE" ] +maintainers = [ { name = "Maintainer", email = "maintainer@example.com" } ] +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", +] +urls.Source = "https://github.com/example/seeder" + +[tool.hatch] +build.hooks.custom = {} +build.targets.wheel.packages = [ "src/seeder" ] + +[tool.python-sbom] +supplier = { name = "Example Org", url = [ "https://example.org" ] } +bundled-wheels = [ "src/seeder/embed/*.whl" ] diff --git a/tests/_seeder/src/seeder/__init__.py b/tests/_seeder/src/seeder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_seeder/src/seeder/embed/__init__.py b/tests/_seeder/src/seeder/embed/__init__.py new file mode 100644 index 0000000..1f40729 --- /dev/null +++ b/tests/_seeder/src/seeder/embed/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pathlib import Path + +# the layout of virtualenv's seed/wheels/embed/__init__.py: another assignment comes before the table +BUNDLE_FOLDER = Path(__file__).absolute().parent +BUNDLE_SUPPORT = { + "3.14": {"pip": "pip-1.0-py3-none-any.whl"}, + "3.15": {"pip": "pip-1.0-py3-none-any.whl"}, +} diff --git a/tests/_validate/test_artifact.py b/tests/_validate/test_artifact.py new file mode 100644 index 0000000..96f17fa --- /dev/null +++ b/tests/_validate/test_artifact.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +import zipfile +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._errors import WheelError +from python_sbom._formats._json import dumps +from python_sbom._validate._artifact import validate + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import Describe, WriteWheel + +_METADATA: Final[dict[str, bytes]] = {"example-1.0.dist-info/METADATA": b"Name: Example\nVersion: 1.0\n"} + + +def test_archive_without_sbom(tmp_path: Path, write_wheel: WriteWheel) -> None: + wheel = write_wheel(tmp_path / "example-1.0-py3-none-any.whl", _METADATA) + assert validate(wheel, None) == ["no SBOM found in example-1.0-py3-none-any.whl"] + + +def test_every_sbom_is_checked(tmp_path: Path, write_wheel: WriteWheel, describe: Describe) -> None: + # the schema makes the serial optional, so the loaded JSON, not the typed document, loses it + broken = json.loads(dumps(describe())) + del broken["serialNumber"] + foreign = json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1}).encode() + wheel = write_wheel( + tmp_path / "example-1.0-py3-none-any.whl", + { + **_METADATA, + "example-1.0.dist-info/sboms/example.cdx.json": json.dumps(broken).encode(), + "example-1.0.dist-info/sboms/other.cdx.json": foreign, + "example-1.0.dist-info/sboms/notes.txt": b"not an SBOM", + "app.pyz.cdx.json": foreign, + "example/nested.cdx.json": json.dumps(broken).encode(), + "example/sboms/nested.cdx.json": json.dumps(broken).encode(), + }, + ) + assert validate(wheel, None) == [ + "example-1.0.dist-info/sboms/example.cdx.json: serialNumber must be a urn:uuid: RFC 4122 UUID, got ''" + ] + + +def test_sbom_of_every_dist_info_is_checked(tmp_path: Path, write_wheel: WriteWheel, describe: Describe) -> None: + # a decoy .dist-info sorting first must not hide the SBOM of the wheel's own one + broken = json.loads(dumps(describe())) + del broken["serialNumber"] + wheel = write_wheel( + tmp_path / "example-1.0-py3-none-any.whl", + { + "aaa-0.dist-info/METADATA": b"Name: aaa\nVersion: 0\n", + "aaa-0.dist-info/sboms/aaa.cdx.json": dumps(describe()), + **_METADATA, + "example-1.0.dist-info/sboms/example.cdx.json": json.dumps(broken).encode(), + }, + record=False, + ) + assert validate(wheel, None) == [ + "example-1.0.dist-info/sboms/example.cdx.json: serialNumber must be a urn:uuid: RFC 4122 UUID, got ''" + ] + + +def test_oversized_sbom(tmp_path: Path, write_wheel: WriteWheel) -> None: + sbom = b" " * (64 * 1024 * 1024 + 1) + wheel = write_wheel(tmp_path / "example-1.0-py3-none-any.whl", {**_METADATA, "example.cdx.json": sbom}) + with pytest.raises(WheelError, match=r"example\.cdx\.json is over the 67108864 byte limit"): + validate(wheel, None) + + +def test_archive_with_duplicate_member(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "example-1.0-py3-none-any.whl", "w") as archive: + archive.writestr("example.cdx.json", b"{}") + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr("example.cdx.json", b"{}") + with pytest.raises(WheelError, match=r"holds more than one member named example\.cdx\.json"): + validate(wheel, None) + + +def test_corrupt_archive(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "example-1.0-py3-none-any.whl", "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("example.cdx.json", b"{}" * 1000) + data = bytearray(wheel.read_bytes()) + data[40:50] = b"\xff" * 10 + wheel.write_bytes(bytes(data)) + with pytest.raises(WheelError, match=r"example-1\.0-py3-none-any\.whl is not a valid zip archive"): + validate(wheel, None) + + +def test_json_file(tmp_path: Path, describe: Describe) -> None: + (sbom := tmp_path / "example.cdx.json").write_bytes(dumps(describe())) + assert validate(sbom, frozenset({"MIT"})) == [] + + +def test_json_file_component_cap(tmp_path: Path) -> None: + components = [{"type": "library", "name": name} for name in ("one", "two", "three")] + (sbom := tmp_path / "example.cdx.json").write_text(json.dumps({"components": components}), encoding="utf-8") + assert validate(sbom, None, max_components=2) == [ + "lists more than 2 components, nested ones included; pass a higher --max-components to check it" + ] diff --git a/tests/_validate/test_document.py b/tests/_validate/test_document.py new file mode 100644 index 0000000..64a40bc --- /dev/null +++ b/tests/_validate/test_document.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Final, TypeAlias + +import pytest + +from python_sbom._formats._json import dumps +from python_sbom._validate._document import is_document, validate_document + +if TYPE_CHECKING: + from collections.abc import Callable + + from conftest import Describe + from pytest_mock import MockerFixture + + from python_sbom import Component, License + from python_sbom._model._types import Document + +# a JSON value, as json.loads hands it over +_Json: TypeAlias = "str | int | float | bool | list[_Json] | dict[str, _Json] | None" +# what auditwheel writes: CycloneDX 1.4 conventions, a legacy tools array, no timestamp +_FOREIGN: Final[str] = json.dumps({ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": {"type": "library", "bom-ref": "pkg:pypi/plain@1.0", "name": "plain", "version": "1.0"}, + "tools": [{"name": "auditwheel", "version": "6.4.0"}], + }, + "components": [{"type": "library", "bom-ref": "pkg:rpm/almalinux/libfoo@1", "name": "libfoo", "version": "1"}], + "dependencies": [{"ref": "pkg:pypi/plain@1.0", "dependsOn": ["pkg:rpm/almalinux/libfoo@1"]}], +}) + + +def test_valid(describe: Describe) -> None: + assert validate_document(dumps(describe("Requires-Dist: click\nLicense-Expression: MIT\n")).decode(), None) == [] + + +def test_not_json() -> None: + assert validate_document("this is not json", None) == ["is not JSON: Expecting value: line 1 column 1 (char 0)"] + + +def test_schema_violation() -> None: + (problem,) = validate_document(json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": "one"}), None) + assert problem == "does not conform to the CycloneDX 1.6 schema: 'one' is not of type 'integer' at $.version" + + +def test_other_tools_documents_get_the_schema_check_alone() -> None: + assert validate_document(_FOREIGN, None) == [] + + +def test_oversized_document_is_not_parsed() -> None: + assert validate_document(" " * (16 * 1024 * 1024 + 1), None) == [ + "is 16777217 characters long, over the 16777216 character limit" + ] + + +def test_component_cap_counts_nested_components() -> None: + assert validate_document(many_components(), None) == [ + "lists more than 5000 components, nested ones included; pass a higher --max-components to check it" + ] + + +def many_components() -> str: + # 100 groups of 50 parts, 5,100 components in all, yet no array long enough to slow the schema check + groups = [ + { + "type": "library", + "name": f"group-{group}", + "components": [{"type": "library", "name": f"part-{part}"} for part in range(50)], + } + for group in range(100) + ] + return json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": groups}) + + +def test_component_cap_can_be_raised() -> None: + assert validate_document(many_components(), None, max_components=5_100) == [] + + +def test_missing_fields(describe: Describe) -> None: + # the schema makes the timestamp optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + del loaded["metadata"]["timestamp"] + assert validate_document(json.dumps(loaded), None) == [ + "lacks metadata.timestamp, which every document python-sbom writes carries" + ] + + +def test_serial_is_required(describe: Describe) -> None: + # the schema makes it optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + del loaded["serialNumber"] + assert validate_document(json.dumps(loaded), None) == ["serialNumber must be a urn:uuid: RFC 4122 UUID, got ''"] + + +def test_duplicate_refs(describe: Describe) -> None: + library: Component = {"type": "library", "bom-ref": "pkg:pypi/example@1.0", "name": "copy"} + assert validate_document(with_components(describe(), library), None) == [ + "bom-ref values are not unique: ['pkg:pypi/example@1.0']" + ] + + +def with_components(document: Document, *components: Component) -> str: + document["components"].extend(components) + document["dependencies"][0].setdefault("dependsOn", []).extend(component["bom-ref"] for component in components) + return dumps(document).decode() + + +def test_duplicate_refs_in_other_tools_documents() -> None: + foreign = json.loads(_FOREIGN) + foreign["components"].append({"type": "library", "bom-ref": "pkg:pypi/plain@1.0", "name": "copy"}) + assert validate_document(json.dumps(foreign), None) == ["bom-ref values are not unique: ['pkg:pypi/plain@1.0']"] + + +def test_component_without_bom_ref(describe: Describe) -> None: + # the schema makes the bom-ref optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + loaded["components"].append({"type": "library", "name": "nameless"}) + assert validate_document(json.dumps(loaded), None) == ["components lack a bom-ref: ['nameless']"] + + +@pytest.mark.parametrize( + "mutate", + [ + pytest.param( + lambda loaded: loaded["formulation"][0]["workflows"][0].pop("resourceReferences"), + id="workflow-without-resources", + ), + pytest.param( + lambda loaded: loaded["formulation"][0]["workflows"][0]["resourceReferences"].append({ + "externalReference": {"type": "other", "url": "https://example.org"} + }), + id="workflow-external-resource", + ), + pytest.param( + lambda loaded: loaded["metadata"]["properties"].append({"name": "example:extra"}), + id="property-without-value", + ), + pytest.param( + lambda loaded: loaded["metadata"]["component"]["properties"].append({"name": "example:extra"}), + id="root-property-without-value", + ), + pytest.param( + lambda loaded: loaded["metadata"].update(supplier={"url": ["https://example.org"]}), + id="supplier-without-name", + ), + ], +) +def test_schema_valid_variations_pass(describe: Describe, mutate: Callable[[dict[str, _Json]], None]) -> None: + loaded = json.loads(dumps(describe())) + mutate(loaded) + assert validate_document(json.dumps(loaded), None) == [] + + +def test_unknown_dependency_refs(describe: Describe) -> None: + document = describe() + document["dependencies"].append({"ref": "ghost", "dependsOn": ["phantom"]}) + assert validate_document(dumps(document).decode(), None) == [ + "dependencies reference bom-refs that do not exist: ['ghost', 'phantom']" + ] + + +def test_root_must_depend_on_components(describe: Describe) -> None: + document = describe("Requires-Dist: click\n") + document["dependencies"][0]["dependsOn"] = [] + assert validate_document(dumps(document).decode(), None) == [ + "root dependsOn [] != components ['requires-dist:click']" + ] + + +def test_workflow_must_reference_tools(describe: Describe) -> None: + document = describe() + document.get("formulation", [])[0]["workflows"][0]["resourceReferences"].append({"ref": "pkg:pypi/example@1.0"}) + assert validate_document(dumps(document).decode(), None) == [ + "workflow wheel-build references unknown tools: ['pkg:pypi/example@1.0']" + ] + + +def test_formula_without_workflows(describe: Describe) -> None: + loaded = json.loads(dumps(describe())) + loaded["formulation"] = [{"bom-ref": "formula:other"}] + assert validate_document(json.dumps(loaded), None) == [] + + +def test_spdx_rendering_must_validate(describe: Describe) -> None: + document = describe() + properties = document["metadata"].get("properties", []) + properties.append({"name": "example:sbom:repository", "value": "https://example.org/a#fragment"}) + (problem,) = validate_document(dumps(document).decode(), None) + assert problem.startswith("SPDX rendering invalid: ") + + +def test_spdx_rendering_must_parse(describe: Describe) -> None: + raw = with_components(describe(), shipped([{"expression": "MIT AND", "acknowledgement": "declared"}])) + assert any(problem.startswith("SPDX rendering does not parse: ") for problem in validate_document(raw, None)) + + +def shipped(licenses: list[License]) -> Component: + return { + "type": "library", + "bom-ref": "bundled:lib.whl", + "name": "lib", + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "licenses": licenses, + } + + +def test_deep_nesting_is_reported(describe: Describe) -> None: + nested = "".join( + f'{{"type": "library", "bom-ref": "deep-{level}", "name": "deep", "components": [' for level in range(500) + ) + nested += "]}" * 500 + raw = json.dumps(describe()).replace('"components": []', f'"components": [{nested}]', 1) + assert validate_document(raw, None) == [ + "does not conform to the CycloneDX 1.6 schema: it nests too deeply to check" + ] + + +def test_license_policy(describe: Describe) -> None: + raw = with_components(describe(), shipped([{"expression": "MIT AND GPL-3.0-only"}])) + assert validate_document(raw, frozenset({"MIT"})) == [ + "bundled:lib.whl: license MIT AND GPL-3.0-only is outside the allowed licenses ['MIT']" + ] + + +def test_unexpected_error_is_reported(describe: Describe, mocker: MockerFixture) -> None: + mocker.patch("python_sbom._validate._document.render_spdx", autospec=True, side_effect=RuntimeError("x" * 300)) + assert validate_document(dumps(describe()).decode(), None) == [f"could not be checked, RuntimeError: {'x' * 200}"] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(json.loads(_FOREIGN), True, id="foreign"), + pytest.param({"metadata": {"component": {"name": "plain"}}}, False, id="root-without-bom-ref"), + pytest.param({"metadata": {"component": {"bom-ref": "plain"}}}, False, id="root-without-name"), + pytest.param({"metadata": {}}, False, id="no-root"), + pytest.param([], False, id="not-an-object"), + ], +) +def test_is_document(value: _Json, expected: bool) -> None: + assert is_document(value) is expected diff --git a/tests/_validate/test_licenses.py b/tests/_validate/test_licenses.py new file mode 100644 index 0000000..237dc6c --- /dev/null +++ b/tests/_validate/test_licenses.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._validate._licenses import license_problems + +if TYPE_CHECKING: + from python_sbom import Component, License + +_ALLOWED: Final[frozenset[str]] = frozenset({"MIT", "BSD-3-Clause", "GPL-2.0-only"}) + + +@pytest.mark.parametrize( + "licenses", + [ + pytest.param([{"expression": "MIT"}], id="expression"), + pytest.param([{"license": {"id": "mit", "acknowledgement": "concluded"}}], id="id-case-insensitive"), + pytest.param([{"expression": "MIT OR GPL-3.0-only"}], id="any-of"), + pytest.param([{"expression": "(GPL-3.0-only OR MIT) AND BSD-3-Clause"}], id="grouped"), + pytest.param([{"expression": "GPL-2.0-only WITH Classpath-exception-2.0"}], id="exception"), + ], +) +def test_allows(licenses: list[License]) -> None: + assert license_problems([shipped(licenses)], _ALLOWED) == [] + + +def shipped(licenses: list[License]) -> Component: + return { + "type": "library", + "bom-ref": "bundled:lib.whl", + "name": "lib", + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "licenses": licenses, + } + + +@pytest.mark.parametrize( + ("licenses", "problem"), + [ + pytest.param( + [{"expression": "MIT AND GPL-3.0-only"}], + "license MIT AND GPL-3.0-only is outside the allowed licenses ['BSD-3-Clause', 'GPL-2.0-only', 'MIT']", + id="denied", + ), + pytest.param( + [{"license": {"name": "BSD License", "acknowledgement": "declared"}}], + "declares license names ['BSD License'] with no SPDX id", + id="names", + ), + pytest.param([], "declares no license", id="none"), + pytest.param( + [{"expression": "MIT AND"}], + "license expression 'MIT AND' is malformed: AND requires two or more licenses as in: MIT AND BSD", + id="end", + ), + pytest.param([{"expression": "(MIT"}], "license expression '(MIT' is malformed: Invalid expression", id="open"), + pytest.param( + [{"expression": "(MIT ISC)"}], + "license expression '(MIT ISC)' is malformed: Invalid symbols sequence such as (A B) for token: \"ISC\" at" + " position: 5", + id="pair", + ), + pytest.param( + [{"expression": "MIT)"}], + "license expression 'MIT)' is malformed: Unbalanced parenthesis for token: \")\" at position: 3", + id="close", + ), + pytest.param( + [{"expression": "AND MIT"}], + "license expression 'AND MIT' is malformed: Invalid operator sequence without symbols such as AND OR or OR" + ' OR for token: "AND"', + id="op", + ), + pytest.param( + [{"expression": "MIT WITH OR"}], + "license expression 'MIT WITH OR' is malformed: Invalid expression for token: \"WITH\" at position: 4", + id="with", + ), + pytest.param([{"expression": " "}], "license expression ' ' is malformed: it is empty", id="empty"), + pytest.param( + [{"expression": "( OR MIT"}], + "license expression '( OR MIT' is malformed: it does not parse", + id="assertion", + ), + pytest.param([{"expression": "()"}], "license expression '()' is malformed: it does not parse", id="no-group"), + ], +) +def test_reports(licenses: list[License], problem: str) -> None: + assert license_problems([shipped(licenses)], _ALLOWED) == [f"bundled:lib.whl: {problem}"] + + +@pytest.mark.parametrize( + "expression", + [ + pytest.param(f"{'(' * 5000}MIT{')' * 5000}", id="parentheses"), + pytest.param( + f"{''.join(f'MIT {operator} (' for operator in ('AND', 'OR') * 2500)}MIT{')' * 5000}", id="operators" + ), + ], +) +def test_deep_nesting_is_allowed(expression: str) -> None: + # a recursive walk would exhaust the stack this deep + assert license_problems([shipped([{"expression": expression}])], _ALLOWED) == [] + + +def test_deep_nesting_is_denied() -> None: + expression = f"{'MIT AND (' * 5000}GPL-3.0-only{')' * 5000}" + assert license_problems([shipped([{"expression": expression}])], _ALLOWED) == [ + f"bundled:lib.whl: license {expression} is outside the allowed licenses ['BSD-3-Clause', 'GPL-2.0-only', 'MIT']" + ] + + +@pytest.mark.parametrize( + ("expression", "allowed", "problems"), + [ + pytest.param( + "LicenseRef-Apache", + frozenset({"Apache-2.0"}), + ["bundled:lib.whl: license LicenseRef-Apache is outside the allowed licenses ['Apache-2.0']"], + id="license-ref-is-not-apache", + ), + pytest.param("Net-SNMP", frozenset({"Net-SNMP"}), [], id="spdx-id-is-kept"), + ], +) +def test_judges_the_declared_id_not_an_alias(expression: str, allowed: frozenset[str], problems: list[str]) -> None: + assert license_problems([shipped([{"expression": expression}])], allowed) == problems + + +def test_judges_only_shipped_components() -> None: + declared: Component = {"type": "library", "bom-ref": "requires-dist:click", "name": "click"} + vendored: Component = {"type": "library", "bom-ref": "bundled:outer.whl#vendored", "name": "vendored"} + outer = shipped([{"expression": "MIT"}]) + outer["components"] = [vendored, {"type": "file", "bom-ref": "outer#a.py", "name": "a.py"}] + assert license_problems([declared, outer], frozenset({"MIT"})) == [] + + +def test_component_without_bom_ref_is_named() -> None: + # the schema makes the bom-ref optional, so the loaded JSON, not the typed document, loses it + component = json.loads(json.dumps(shipped([]))) + del component["bom-ref"] + assert license_problems([component], _ALLOWED) == ["lib: declares no license"] diff --git a/tests/_validate/test_schemas.py b/tests/_validate/test_schemas.py new file mode 100644 index 0000000..9221ff9 --- /dev/null +++ b/tests/_validate/test_schemas.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import json +import sys + +import pytest + +from python_sbom._errors import MissingExtraError +from python_sbom._validate._document import validate_document +from python_sbom._validate._schemas import schema_error + + +def test_validate_without_extra(monkeypatch: pytest.MonkeyPatch) -> None: + # None in sys.modules makes the import fail, as when the extra is not installed + monkeypatch.setitem(sys.modules, "cyclonedx.schema", None) + monkeypatch.delitem(sys.modules, "python_sbom._validate._schemas", raising=False) + with pytest.raises(MissingExtraError, match=r"pip install 'python-sbom\[validate\]'"): + validate_document("{}", None) + + +def test_schema_error_names_the_path() -> None: + raw = json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": "one"}) + assert schema_error(raw) == "'one' is not of type 'integer' at $.version" + + +def test_schema_error_clips_the_offending_value() -> None: + raw = json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": "x" * 1000}) + assert schema_error(raw) == f"'{'x' * 199} at $.version" diff --git a/tests/_wheel/test_archive.py b/tests/_wheel/test_archive.py new file mode 100644 index 0000000..24854c3 --- /dev/null +++ b/tests/_wheel/test_archive.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import stat +import struct +import sys +import tracemalloc +import zipfile +from contextlib import ExitStack +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._errors import WheelError +from python_sbom._wheel._archive import open_archive, read_member, write_sbom + +if TYPE_CHECKING: + from collections.abc import Iterator + + from conftest import WriteWheel + from pytest_mock import MockerFixture + +_SBOM: Final[str] = "native-1.0.dist-info/sboms/native.cdx.json" +# the SHA-256 of b"{}\n", urlsafe base64 without padding +_SBOM_ROW: Final[str] = f"{_SBOM},sha256=yj0WO6sFU4GCciYUBWjzvvfqrBh869doeOC2Pp5EI1Y,3" + + +@pytest.fixture +def wheel(tmp_path: Path, write_wheel: WriteWheel) -> Path: + return write_wheel( + tmp_path / "dist" / "native-1.0-py3-none-any.whl", + {"native/__init__.py": b"", "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n"}, + ) + + +def test_embed_returns_member_path(wheel: Path) -> None: + assert write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) == _SBOM + + +def test_embed_puts_sbom_before_record(wheel: Path) -> None: + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert zipfile.ZipFile(wheel).namelist() == [ + "native/__init__.py", + "native-1.0.dist-info/METADATA", + _SBOM, + "native-1.0.dist-info/RECORD", + ] + + +def test_embed_records_sbom_before_record_row(wheel: Path) -> None: + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + record = zipfile.ZipFile(wheel).read("native-1.0.dist-info/RECORD").decode().splitlines() + assert record[-2:] == [_SBOM_ROW, "native-1.0.dist-info/RECORD,,"] + + +def test_embed_replaces_existing_sbom(wheel: Path) -> None: + write_sbom(wheel, "native.cdx.json", b"[]\n", 1_700_000_000) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + with zipfile.ZipFile(wheel) as archive: + assert archive.read(_SBOM) == b"{}\n" + assert [ + line for line in archive.read("native-1.0.dist-info/RECORD").decode().splitlines() if "sboms" in line + ] == [_SBOM_ROW] + + +def test_embed_keeps_other_entries(wheel: Path) -> None: + before = {info.filename: (info.date_time, info.external_attr) for info in zipfile.ZipFile(wheel).infolist()} + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + after = {info.filename: (info.date_time, info.external_attr) for info in zipfile.ZipFile(wheel).infolist()} + assert {name: after[name] for name in before} == before + + +def test_embed_keeps_other_members_compressed_as_they_were(tmp_path: Path) -> None: + # level 9 over repetitive text gives other bytes than zipfile's default level, so a recompressed member would differ + with zipfile.ZipFile(wheel := tmp_path / "native-1.0-py3-none-any.whl", "w", compresslevel=9) as handle: + handle.writestr( + "native/data.txt", b"".join(b"line %d\n" % number for number in range(5000)), zipfile.ZIP_DEFLATED + ) + handle.writestr("native-1.0.dist-info/METADATA", b"Name: native\nVersion: 1.0\n") + handle.writestr("native-1.0.dist-info/RECORD", b"native-1.0.dist-info/RECORD,,\n") + before = _stored(wheel, "native/data.txt") + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert _stored(wheel, "native/data.txt") == before + + +def _stored(wheel: Path, name: str) -> bytes: + with zipfile.ZipFile(wheel) as archive: + info = archive.getinfo(name) + data = wheel.read_bytes() + name_length, extra_length = struct.unpack(" None: + write_sbom(wheel, "native.cdx.json", b"{}\n", epoch) + info = zipfile.ZipFile(wheel).getinfo(_SBOM) + assert (info.date_time, info.create_system, info.external_attr) == ( + date_time, + 3, + (stat.S_IFREG | 0o644) << 16, + ) + + +def test_embed_is_deterministic(tmp_path: Path, write_wheel: WriteWheel) -> None: + files = {"native/__init__.py": b"", "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n"} + first, second = (write_wheel(tmp_path / name / "native-1.0-py3-none-any.whl", files) for name in ("a", "b")) + for wheel in (first, second): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert first.read_bytes() == second.read_bytes() + + +def test_embed_keeps_record_line_endings(tmp_path: Path, write_wheel: WriteWheel) -> None: + record = b"native-1.0.dist-info/METADATA,,\r\nnative-1.0.dist-info/RECORD,,\r\n" + wheel = write_wheel( + tmp_path / "native-1.0-py3-none-any.whl", + {"native-1.0.dist-info/METADATA": b"Name: native\n", "native-1.0.dist-info/RECORD": record}, + record=False, + ) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert zipfile.ZipFile(wheel).read("native-1.0.dist-info/RECORD") == ( + f"native-1.0.dist-info/METADATA,,\r\n{_SBOM_ROW}\r\nnative-1.0.dist-info/RECORD,,\r\n".encode() + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX permission bits") +def test_embed_keeps_wheel_permissions(wheel: Path) -> None: # pragma: win32 no cover + wheel.chmod(0o640) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert stat.S_IMODE(wheel.stat().st_mode) == 0o640 + + +@pytest.mark.parametrize( + ("files", "message"), + [ + pytest.param( + {"native/__init__.py": b""}, + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="no-dist-info", + ), + pytest.param( + {"native-1.0.dist-info/RECORD": b""}, + "has no native-1.0.dist-info/METADATA", + id="no-metadata", + ), + pytest.param( + {"native-1.0.dist-info/METADATA": b"Name: native\n"}, + "has no native-1.0.dist-info/RECORD", + id="no-record", + ), + ], +) +def test_embed_refuses_non_wheel(tmp_path: Path, files: dict[str, bytes], message: str) -> None: + with zipfile.ZipFile(archive := tmp_path / "native-1.0-py3-none-any.whl", "w") as handle: + for name, content in files.items(): + handle.writestr(name, content) + with pytest.raises(WheelError, match=message): + write_sbom(archive, "native.cdx.json", b"{}\n", 1_700_000_000) + + +def test_failed_embed_leaves_wheel_and_no_temporary_file(wheel: Path, mocker: MockerFixture) -> None: + original = wheel.read_bytes() + mocker.patch.object(Path, "replace", autospec=True, side_effect=OSError("disk full")) + with pytest.raises(OSError, match="disk full"): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert [path.name for path in wheel.parent.iterdir()] == [wheel.name] + assert wheel.read_bytes() == original + + +def test_embed_refuses_duplicate_member(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "native-1.0-py3-none-any.whl", "w") as handle: + handle.writestr("native-1.0.dist-info/METADATA", b"Name: native\n") + handle.writestr("native-1.0.dist-info/RECORD", b"") + with pytest.warns(UserWarning, match="Duplicate name"): + handle.writestr("native-1.0.dist-info/RECORD", b"") + with pytest.raises(WheelError, match=r"holds more than one member named native-1\.0\.dist-info/record"): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + + +def test_embed_refuses_corrupt_archive(tmp_path: Path) -> None: + (wheel := tmp_path / "native-1.0-py3-none-any.whl").write_bytes(b"not a zip") + with pytest.raises(WheelError, match="is not a valid zip archive: File is not a zip file"): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + + +@pytest.mark.parametrize( + "names", + [ + pytest.param(("a.py", "A.py"), id="case"), + pytest.param(("pkg/a.py", "PKG/a.py"), id="directory-case"), + ], +) +def test_open_archive_refuses_names_equal_ignoring_case(names: tuple[str, str]) -> None: + message = f"w holds more than one member named {names[0].lower()}, ignoring case" + with pytest.raises(WheelError, match=message), ExitStack() as stack: + stack.enter_context(open_archive(zip_of(*((name, b"") for name in names)), "w")) + + +def zip_of(*members: tuple[str, bytes]) -> BytesIO: + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in members: + archive.writestr(name, content) + return buffer + + +def test_open_archive_refuses_corrupt_member() -> None: + data = bytearray(zip_of(("a", b"hello" * 1000)).getvalue()) + data[40:50] = b"\xff" * 10 + with pytest.raises(WheelError, match="w is not a valid zip"), open_archive(BytesIO(bytes(data)), "w") as archive: + archive.read("a") + + +def test_read_member() -> None: + with open_archive(zip_of(("a", b"12345")), "w") as archive: + assert read_member(archive, "a", "w", limit=5) == b"12345" + + +def test_read_member_over_limit() -> None: + with open_archive(zip_of(("a", b"12345")), "w") as archive, pytest.raises(WheelError, match="w: a is over the 4"): + read_member(archive, "a", "w", limit=4) + + +def test_read_member_missing() -> None: + with open_archive(zip_of(("a", b"")), "w") as archive, pytest.raises(WheelError, match="w has no b"): + read_member(archive, "b", "w") + + +@pytest.mark.usefixtures("traced") +def test_read_member_refuses_understated_size() -> None: + # the archive claims 10 bytes for 32 MiB; reading the member whole would decompress all of it before the CRC check + data = bytearray(zip_of(("a", b"A" * 32 * 1024 * 1024)).getvalue()) + data[22:26] = data[(central := data.rfind(b"PK\x01\x02")) + 24 : central + 28] = struct.pack(" Iterator[None]: + tracemalloc.start() + yield + tracemalloc.stop() + + +def test_wheel_with_two_dist_info_directories(tmp_path: Path, write_wheel: WriteWheel) -> None: + wheel = write_wheel( + tmp_path / "native-1.0-py3-none-any.whl", + { + "aaa-0.dist-info/METADATA": b"Name: aaa\nVersion: 0\n", + "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n", + }, + record=False, + ) + message = r"Wheel doesn't contain exactly one \.dist-info directory .*'aaa-0\.dist-info', 'native-1\.0\.dist-info'" + with pytest.raises(WheelError, match=message): + write_sbom(wheel, "native.cdx.json", b"{}\n", 0) diff --git a/tests/_wheel/test_record.py b/tests/_wheel/test_record.py new file mode 100644 index 0000000..3bb24a7 --- /dev/null +++ b/tests/_wheel/test_record.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import hashlib + +import pytest + +from python_sbom._errors import WheelError +from python_sbom._wheel._record import decode_digest + + +@pytest.mark.parametrize( + ("digest", "expected"), + [ + pytest.param( + "sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", + ("SHA-256", hashlib.sha256(b"").hexdigest()), + id="sha256", + ), + pytest.param( + "md5=1B2M2Y8AsgTpgAmY7PhCfg", ("MD5", hashlib.md5(b"", usedforsecurity=False).hexdigest()), id="md5" + ), + ], +) +def test_decode_digest(digest: str, expected: tuple[str, str]) -> None: + assert decode_digest(digest, "w") == expected + + +@pytest.mark.parametrize( + ("digest", "message"), + [ + pytest.param("blake2b=AAAA", "w: unknown hash algorithm 'blake2b'", id="algorithm"), + pytest.param("sha256=abcde", "w: digest 'abcde' is not urlsafe base64", id="base64"), + ], +) +def test_decode_digest_rejects(digest: str, message: str) -> None: + with pytest.raises(WheelError, match=message): + decode_digest(digest, "w") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..21eee23 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import base64 +import hashlib +import shutil +import subprocess +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +import pytest + +from python_sbom import ContentProvider, ProviderContext +from python_sbom._generate._pipeline import build_sbom +from python_sbom._model._identity import project_identity +from python_sbom._model._inputs import BuildFacts, Interpreter, SbomRequest, Settings, SourceTree +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from collections.abc import Mapping + + from python_sbom._model._types import Document + + +class WriteWheel(Protocol): + def __call__( + self, wheel: Path, files: Mapping[str, bytes], *, record: bool = True, compression: int = ... + ) -> Path: ... + + +class Install(Protocol): + def __call__( + self, site: Path, metadata: str, files: Mapping[str, bytes], *, purelib: bool = True, direct_url: str = "" + ) -> None: ... + + +class Git(Protocol): + def __call__(self, *arguments: str) -> str: ... + + +class ContextFor(Protocol): + def __call__(self, root: Path, artifact: Path) -> ProviderContext: ... + + +class Describe(Protocol): + def __call__( + self, + metadata: str = "", + *, + settings: Settings = ..., + facts: BuildFacts = ..., + source: SourceTree | None = None, + content: ContentProvider | None = None, + ) -> Document: ... + + +@pytest.fixture +def write_wheel() -> WriteWheel: + def write( + wheel: Path, files: Mapping[str, bytes], *, record: bool = True, compression: int = zipfile.ZIP_DEFLATED + ) -> Path: + members = dict(files) + if record: + dist_info = next(name.split("/")[0] for name in files if name.endswith(".dist-info/METADATA")) + rows = "".join(f"{name},{record_digest(content)},{len(content)}\n" for name, content in files.items()) + members[f"{dist_info}/RECORD"] = f"{rows}{dist_info}/RECORD,,\n".encode() + wheel.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(wheel, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in members.items(): + # Unix, the default everywhere but Windows, so the bytes match across operating systems + (info := zipfile.ZipInfo(name, (2024, 1, 1, 0, 0, 0))).create_system = 3 + archive.writestr(info, content, compression) + return wheel + + return write + + +def record_digest(content: bytes) -> str: + return f"sha256={base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b'=').decode()}" + + +@pytest.fixture +def install() -> Install: + def install( + site: Path, metadata: str, files: Mapping[str, bytes], *, purelib: bool = True, direct_url: str = "" + ) -> None: + name, version = (line.split(": ", 1)[1] for line in metadata.splitlines()[:2]) + dist_info = f"{name}-{version}.dist-info" + members = { + **files, + f"{dist_info}/METADATA": metadata.encode(), + f"{dist_info}/WHEEL": f"Wheel-Version: 1.0\nRoot-Is-Purelib: {str(purelib).lower()}\n".encode(), + } + if direct_url: + members[f"{dist_info}/direct_url.json"] = direct_url.encode() + rows = "".join(f"{path},{record_digest(content)},{len(content)}\n" for path, content in members.items()) + members[f"{dist_info}/RECORD"] = f"{rows}{dist_info}/RECORD,,\n".encode() + for path, content in members.items(): + (target := site / path).parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + return install + + +@pytest.fixture +def git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Git: + # the user's git configuration, such as commit signing, stays out of the test repository + (config := tmp_path / "gitconfig").write_text("[user]\nname = Test\nemail = test@example.com\n", encoding="utf-8") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + (checkout := tmp_path / "checkout").mkdir() + executable = shutil.which("git") + assert executable is not None + + def run(*arguments: str) -> str: + return subprocess.run( + [executable, "-C", str(checkout), *arguments], check=True, capture_output=True, text=True + ).stdout.strip() + + run("init", "--quiet") + return run + + +@pytest.fixture +def facts() -> BuildFacts: + return BuildFacts( + epoch=1_700_000_000, + epoch_from_environment=False, + revision=None, + interpreter=Interpreter(name="cpython", version="3.15.0", implementation="CPython"), + distributions=[], + ) + + +@pytest.fixture +def settings() -> Settings: + return Settings(supplier=None, repository=None, default_branch="main", bundled_wheels=(), content_provider=None) + + +@pytest.fixture +def describe(settings: Settings, facts: BuildFacts) -> Describe: + def describe( + metadata: str = "", + *, + settings: Settings = settings, + facts: BuildFacts = facts, + source: SourceTree | None = None, + content: ContentProvider | None = None, + ) -> Document: + request = SbomRequest( + root=Path(), + metadata=parse_metadata(f"Name: Example\nVersion: 1.0\n{metadata}"), + wheel_name="example-1.0-py3-none-any.whl", + settings=settings, + build_scripts=(), + ) + context = ProviderContext( + root=Path(), + settings=settings, + identity=project_identity(request.metadata, settings), + artifact=Path(), + ) + return build_sbom( + request, + source or SourceTree(copyright=None, bundled=[], build_scripts=[]), + facts, + content or ContentProvider(), + context, + ).document + + return describe + + +@pytest.fixture +def context_for(settings: Settings) -> ContextFor: + def context(root: Path, artifact: Path) -> ProviderContext: + identity = project_identity(parse_metadata("Name: Example\nVersion: 1.0\n"), settings) + return ProviderContext(root=root, settings=settings, identity=identity, artifact=artifact) + + return context diff --git a/tests/hatch/conftest.py b/tests/hatch/conftest.py new file mode 100644 index 0000000..cdba574 --- /dev/null +++ b/tests/hatch/conftest.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Final, Protocol + +import pytest +from hatchling.builders.sdist import SdistBuilder +from hatchling.builders.wheel import WheelBuilder + +if TYPE_CHECKING: + from tests.conftest import WriteWheel + + +class BuildWheel(Protocol): + def __call__(self, root: Path, *, version: str = "standard", target: str = "wheel") -> Path: ... + + +_SEEDER: Final[Path] = Path(__file__).parent.parent / "_seeder" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + project = """\ +[project] +name = "Example" +version = "1.0" +description = "an example" +license = "MIT" +license-files = ["LICENSE"] +dependencies = ["packaging>=24"] +urls.Source = "https://github.com/example/example" + +[tool.hatch.build.targets.wheel] +packages = ["src/example"] +""" + (package := tmp_path / "project" / "src" / "example").mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "project" / "LICENSE").write_text("Copyright (c) 2026 Example\n\nMIT terms\n", encoding="utf-8") + (tmp_path / "project" / "pyproject.toml").write_text( + f"{project}\n[tool.hatch.build.hooks.python-sbom]\n", encoding="utf-8" + ) + return tmp_path / "project" + + +@pytest.fixture +def build_wheel(tmp_path: Path) -> BuildWheel: + def build(root: Path, *, version: str = "standard", target: str = "wheel") -> Path: + builder = (WheelBuilder if target == "wheel" else SdistBuilder)(str(root)) + (artifact,) = builder.build(directory=str(tmp_path / "dist"), versions=[version]) + return Path(artifact) + + return build + + +@pytest.fixture +def seeder(tmp_path: Path, write_wheel: WriteWheel) -> Path: + # a copy, so the build leaves nothing in the source tree; the hook script stays the checked-in one, so coverage + # measures it + shutil.copytree(_SEEDER, target := tmp_path / "seeder", ignore=shutil.ignore_patterns("hatch_build.py")) + pyproject = target / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "build.hooks.custom = {}", + f'build.hooks.custom.path = "{(_SEEDER / "hatch_build.py").as_posix()}"', + ), + encoding="utf-8", + ) + # the pip wheel a seeder bundles, built here so the repository holds no binaries + write_wheel( + target / "src" / "seeder" / "embed" / "pip-1.0-py3-none-any.whl", + { + "pip/__init__.py": b"", + "pip/_vendor/vendor.txt": b"urllib3==1.26.4\n", + "pip-1.0.dist-info/METADATA": b"Metadata-Version: 2.4\nName: pip\nVersion: 1.0\nLicense-Expression: MIT\n", + "pip-1.0.dist-info/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + }, + ) + return target diff --git a/tests/hatch/test_hook.py b/tests/hatch/test_hook.py new file mode 100644 index 0000000..4a32557 --- /dev/null +++ b/tests/hatch/test_hook.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import shutil +import tarfile +import time +import zipfile +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._errors import SettingsError +from python_sbom._validate._artifact import validate + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import BuildWheel + + from python_sbom._model._types import Document + from tests.conftest import Git + +_SBOM: Final[str] = "example-1.0.dist-info/sboms/example.cdx.json" +# a later hook that changes what the wheel declares and how it is tagged, after the SBOM hook's turn +_RETAG: Final[str] = """\ +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class Retag(BuildHookInterface): + def initialize(self, version, build_data): + build_data["dependencies"].append("late-dependency>=1") + build_data["tag"] = "py3-none-manylinux_2_17_x86_64" +""" + + +def sbom_of(wheel: Path, name: str = _SBOM) -> Document: + return json.loads(zipfile.ZipFile(wheel).read(name)) + + +def test_wheel_carries_valid_sbom(project: Path, build_wheel: BuildWheel) -> None: + assert validate(build_wheel(project), frozenset({"MIT"})) == [] + + +def test_sbom_describes_the_built_wheel(project: Path, build_wheel: BuildWheel) -> None: + (project / "hatch_build.py").write_text(_RETAG, encoding="utf-8") + configure(project, "[tool.hatch.build.hooks.custom]\n") + document = sbom_of(wheel := build_wheel(project)) + references = { + reference["type"]: reference["url"] for reference in document["metadata"]["component"]["externalReferences"] + } + assert [component["name"] for component in document["components"]] == ["packaging", "late-dependency"] + assert references["attestation"] == f"https://pypi.org/integrity/Example/1.0/{wheel.name}/provenance" + assert wheel.name == "example-1.0-py3-none-manylinux_2_17_x86_64.whl" + + +def configure(root: Path, tables: str) -> None: + pyproject = root / "pyproject.toml" + pyproject.write_text(f"{pyproject.read_text(encoding='utf-8')}{tables}", encoding="utf-8") + + +def test_project_table_options(project: Path, build_wheel: BuildWheel) -> None: + configure( + project, + "enable-by-default = true\nrequire-runtime-dependencies = false\n" + "[tool.python-sbom]\ndefault-branch = 'dev'\nsupplier = { name = 'Example Org' }\n", + ) + root = sbom_of(build_wheel(project))["metadata"]["component"] + assert (root.get("supplier"), root["externalReferences"][-1]) == ( + {"name": "Example Org", "url": []}, + {"type": "license", "url": "https://github.com/example/example/blob/dev/LICENSE"}, + ) + + +def test_hook_table_refuses_python_sbom_options(project: Path, build_wheel: BuildWheel) -> None: + configure(project, "default-branch = 'trunk'\n") + with pytest.raises(SettingsError, match=r"hook's table holds default-branch; move python-sbom's options"): + build_wheel(project) + + +def test_reproducible_build_uses_hatchling_time(project: Path, build_wheel: BuildWheel) -> None: + assert sbom_of(build_wheel(project))["metadata"]["timestamp"] == "2020-02-02T00:00:00+00:00" + + +def test_non_reproducible_build_uses_current_time(project: Path, build_wheel: BuildWheel) -> None: + (project / "pyproject.toml").write_text( + (project / "pyproject.toml") + .read_text(encoding="utf-8") + .replace("[tool.hatch.build.targets.wheel]\n", "[tool.hatch.build.targets.wheel]\nreproducible = false\n"), + encoding="utf-8", + ) + before = time.time() + timestamp = sbom_of(build_wheel(project))["metadata"]["timestamp"] + assert timestamp >= time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(int(before))) + + +def test_source_date_epoch_wins(project: Path, build_wheel: BuildWheel, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + assert sbom_of(build_wheel(project))["metadata"]["timestamp"] == "2023-11-14T22:13:20+00:00" + + +def test_editable_wheel_has_no_sbom(project: Path, build_wheel: BuildWheel) -> None: + # a .pth editable, which needs no editables package + pyproject = project / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "[tool.hatch.build.targets.wheel]\n", "[tool.hatch.build.targets.wheel]\ndev-mode-dirs = ['src']\n" + ), + encoding="utf-8", + ) + names = zipfile.ZipFile(build_wheel(project, version="editable")).namelist() + assert not [name for name in names if "/sboms/" in name] + + +def test_sdist_has_no_sbom(project: Path, build_wheel: BuildWheel) -> None: + configure(project, "[tool.hatch.build.targets.sdist.hooks.python-sbom]\n") + sdist = build_wheel(project, version="standard", target="sdist") + assert sdist.name.endswith(".tar.gz") + assert "sboms" not in sdist.read_bytes().decode("latin-1") + + +def test_content_provider_option(project: Path, build_wheel: BuildWheel, tmp_path: Path) -> None: + (project / f"{tmp_path.name}.py").write_text( + "from python_sbom import ContentProvider, Contributions\n\n\n" + "class Content(ContentProvider):\n" + " def contributions(self, context):\n" + " return Contributions(" + " root_properties=[{'name': 'extra:by', 'value': 'hook'}])\n", + encoding="utf-8", + ) + configure(project, f"[tool.python-sbom]\ncontent-provider = '{tmp_path.name}:Content'\n") + document = sbom_of(build_wheel(project)) + assert {"name": "extra:by", "value": "hook"} in document["metadata"]["component"]["properties"] + assert document["metadata"]["tools"]["components"][0]["name"] == f"{tmp_path.name}.py" + + +def test_seeder_bundles_described(seeder: Path, build_wheel: BuildWheel) -> None: + (pip,) = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json")["components"] + assert pip.get("properties", [])[:3] == [ + {"name": "seeder:bundled-wheel", "value": "seeder/embed/pip-1.0-py3-none-any.whl"}, + {"name": "seeder:seeded-for-python", "value": "3.14"}, + {"name": "seeder:seeded-for-python", "value": "3.15"}, + ] + + +def test_seeder_hook_script_is_a_tool(seeder: Path, build_wheel: BuildWheel) -> None: + document = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json") + assert document["metadata"]["tools"]["components"][0]["name"] == "hatch_build.py" + + +def test_seeder_supplier(seeder: Path, build_wheel: BuildWheel) -> None: + document = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json") + assert document["metadata"].get("supplier") == {"name": "Example Org", "url": ["https://example.org"]} + + +def test_seeder_bundled_wheel_supplier(seeder: Path, build_wheel: BuildWheel) -> None: + document = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json") + assert document["components"][0].get("supplier") == {"name": "Example Org", "url": ["https://example.org"]} + + +@pytest.fixture +def checkout(project: Path, tmp_path: Path, git: Git) -> Path: + shutil.copytree(project, target := tmp_path / "checkout", dirs_exist_ok=True) + git("add", ".") + git("commit", "--quiet", "--message", "first") + return target + + +def test_sdist_records_clean_revision(checkout: Path, git: Git, build_wheel: BuildWheel) -> None: + files = sdist_files(build_wheel(checkout, target="sdist")) + assert files.get(".python-sbom-revision") == f"node: {git('rev-parse', 'HEAD')}\n".encode() + + +def sdist_files(sdist: Path) -> dict[str, bytes]: + with tarfile.open(sdist) as archive: + return { + member.name.split("/", 1)[1]: handle.read() + for member in archive.getmembers() + if (handle := archive.extractfile(member)) is not None + } + + +def test_sdist_of_dirty_checkout_records_no_revision(checkout: Path, build_wheel: BuildWheel) -> None: + (checkout / "LICENSE").write_text("changed\n", encoding="utf-8") + assert ".python-sbom-revision" not in sdist_files(build_wheel(checkout, target="sdist")) diff --git a/tests/test_no_hatchling.py b/tests/test_no_hatchling.py new file mode 100644 index 0000000..9a84f05 --- /dev/null +++ b/tests/test_no_hatchling.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import subprocess +import sys + + +def test_core_imports_without_hatchling() -> None: + # None in sys.modules makes the import fail, as when hatchling is not installed + code = ( + "import sys; sys.modules['hatchling'] = None\n" + "import python_sbom\n" + "try:\n import python_sbom.hatch\nexcept ImportError:\n print('core only')\n" + ) + assert subprocess.check_output([sys.executable, "-c", code], text=True) == "core only\n" diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..54ab95f --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import collections.abc +import importlib +import inspect +import pathlib +import pkgutil +import typing +from typing import Final + +import pytest + +import python_sbom +import python_sbom.hatch + +_PUBLIC: Final[set[str]] = { + "BundledWheelInfo", + "Component", + "Contact", + "ContentProvider", + "Contributions", + "Dependency", + "Evidence", + "ExternalReference", + "Hash", + "Identity", + "IdentityMethod", + "License", + "LicenseInfo", + "OrganizationalEntity", + "ProjectIdentity", + "Property", + "ProviderContext", + "SbomError", + "Settings", + "__version__", +} + + +def test_public_api() -> None: + assert set(python_sbom.__all__) == _PUBLIC + + +def test_hatch_api() -> None: + assert set(python_sbom.hatch.__all__) == {"SbomBuildHook"} + + +def reached(start: object) -> set[object]: # simplint: ignore[SMP602] # hints are arbitrary typing objects + # every python_sbom type a hint of a public name leads to, through fields, parameters and nested hints + found: set[object] = set() # simplint: ignore[SMP602] # see above + pending: list[object] = [start] # simplint: ignore[SMP602] # see above + while pending: + if (item := pending.pop()) in found: + continue + found.add(item) + if typing.is_typeddict(item) or inspect.isclass(item): + pending.extend(typing.get_type_hints(item, globalns=_names(), include_extras=True).values()) + for _, member in inspect.getmembers(item, inspect.isfunction): + if member.__module__.startswith("python_sbom"): + pending.extend(typing.get_type_hints(member, globalns=_names(), include_extras=True).values()) + pending.extend(typing.get_args(item)) + if (origin := typing.get_origin(item)) is not None: + pending.append(origin) + return {item for item in found if getattr(item, "__module__", "").startswith("python_sbom")} + + +@pytest.mark.parametrize("name", sorted(_PUBLIC - {"__version__"}), ids=str) +def test_public_types_only_reach_public_types(name: str) -> None: + assert reached(getattr(python_sbom, name)) - exported() == set() + + +def test_hook_only_reaches_public_types() -> None: + assert reached(python_sbom.hatch.SbomBuildHook) - exported() == set() + + +def exported() -> set[object]: # simplint: ignore[SMP602] # see above + return {getattr(python_sbom, name) for name in _PUBLIC} | {python_sbom.hatch.SbomBuildHook} + + +def _names() -> dict[str, object]: # simplint: ignore[SMP602] # see above + # the annotations name types the modules import only for type checking, so every module's names resolve them + modules = [ + pathlib, + collections.abc, + typing, + *(importlib.import_module(found.name) for found in pkgutil.walk_packages(python_sbom.__path__, "python_sbom.")), + ] + return dict(member for module in modules for member in inspect.getmembers(module)) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..4730451 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from packaging.version import Version + +from python_sbom import __version__ + + +def test_version() -> None: + assert Version(__version__).release diff --git a/tox.toml b/tox.toml new file mode 100644 index 0000000..b6e36da --- /dev/null +++ b/tox.toml @@ -0,0 +1,139 @@ +requires = [ "tox>=4.64.7", "tox-uv>=1.36.1" ] +env_list = [ "3.15", "3.14", "3.13", "3.12", "3.11", "3.14t", "3.15t", "docs", "fix", "pkg_meta", "type" ] +skip_missing_interpreters = true + +[env_run_base] +description = "run the tests with pytest under {env_name}" +default_base_python = [ "3.15" ] +package = "wheel" +wheel_build_env = ".pkg" +dependency_groups = [ "test" ] +pass_env = [ "PYTEST_*" ] +set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{env_tmp_dir}{/}.coverage" } +commands = [ + [ + "pytest", + { replace = "posargs", default = [ + "--no-cov-on-fail", + "--cov", + "python_sbom", + "--cov", + "{tox_root}{/}tests", + "--cov-config", + "{tox_root}{/}pyproject.toml", + "--cov-context", + "test", + "--cov-report", + "term-missing:skip-covered", + "--cov-report", + "html:{env_tmp_dir}{/}htmlcov", + "tests", + ], extend = true }, + ], +] + +[env_pkg_base] +default_base_python = [ "3.15" ] + +[env.docs] +description = "Build documentation" +dependency_groups = [ "docs" ] +commands = [ + [ + "python", + "-c", + # RE2 lacks Python 3.15 wheels: https://github.com/google/re2/issues/647 + """\ + import glob; import pathlib; import subprocess; subprocess.check_call(['uv', 'tool', 'run', '--python', '3.14', \ + '--from', 'proselint>=0.16', 'proselint', 'check', *(path for path in glob.glob('docs/*.rst') if \ + pathlib.Path(path).name != 'changelog.rst')])\ + """, + ], + [ + "sphinx-build", + "-d", + "{env_tmp_dir}/doctree", + "docs", + "{env:READTHEDOCS_OUTPUT:{env_tmp_dir}{/}docs_out}/html", + "--color", + "-b", + "html", + "-W", + "--keep-going", + { replace = "posargs", extend = true }, + ], + [ + "python", + "-c", + """\ + import pathlib; print('documentation available under ' + \ + (pathlib.Path(r'{env:READTHEDOCS_OUTPUT:{env_tmp_dir}{/}docs_out}/html') / 'index.html').as_uri())\ + """, + ], +] + +[env.fix] +description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" +skip_install = true +dependency_groups = [ "lint" ] +pass_env = [ + { replace = "ref", of = [ + "env_run_base", + "pass_env", + ], extend = true }, + "DISABLE_PRE_COMMIT_UV_PATCH", + "PROGRAMDATA", +] +commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs", extend = true } ] ] + +[env.pkg_meta] +description = "check that the long description is valid" +skip_install = true +dependency_groups = [ "pkg_meta" ] +commands = [ + [ + "uv", + "build", + "--sdist", + "--wheel", + "--out-dir", + "{env_tmp_dir}", + ".", + ], + [ + "twine", + "check", + "--strict", + "{env_tmp_dir}{/}*", + ], + [ + "check-wheel-contents", + "--no-config", + "{env_tmp_dir}", + ], + [ + "python", + "-c", + # the project's own wheel carries the SBOM its hook writes, which lists that hook script among the build tools + """\ + import glob, json, zipfile; (wheel,) = glob.glob(r'{env_tmp_dir}{/}*.whl'); archive = zipfile.ZipFile(wheel); \ + (sbom,) = (name for name in archive.namelist() if '/sboms/' in name); tools = \ + json.loads(archive.read(sbom))['metadata']['tools']['components']; assert any(tool['name'] == 'hatch_build.py' for \ + tool in tools), f'hatch_build.py missing from {sbom}'\ + """, + ], +] + +[env.type] +description = "run type check on code base" +dependency_groups = [ "type" ] +commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "--python-platform", "all", "." ] ] + +[env.dev] +description = "dev environment with all deps at {envdir}" +package = "editable" +dependency_groups = [ "dev" ] +commands = [ + [ "uv", "pip", "tree" ], + [ "python", "-c", "import sys; print(sys.executable)" ], +]