diff --git a/.github/workflows/ci-plugin-catalog.yaml b/.github/workflows/ci-plugin-catalog.yaml index 0cacac9..3f0aea5 100644 --- a/.github/workflows/ci-plugin-catalog.yaml +++ b/.github/workflows/ci-plugin-catalog.yaml @@ -6,21 +6,31 @@ on: paths: - "tests/test_plugin_catalog.py" - "tools/plugin_catalog.py" + - "tools/validate_ci_selection.py" + - "pyproject.toml" + - "plugins/python/**" - "plugins/rust/python-package/*/Cargo.toml" - "plugins/rust/python-package/*/plugin-manifest.yaml" - ".github/workflows/ci-plugin-catalog.yaml" - ".github/workflows/ci-rust-python-package.yaml" - ".github/workflows/release-rust-python-package.yaml" + - ".github/workflows/ci-python-package.yaml" + - ".github/workflows/release-python-package.yaml" pull_request: branches: [main] paths: - "tests/test_plugin_catalog.py" - "tools/plugin_catalog.py" + - "tools/validate_ci_selection.py" + - "pyproject.toml" + - "plugins/python/**" - "plugins/rust/python-package/*/Cargo.toml" - "plugins/rust/python-package/*/plugin-manifest.yaml" - ".github/workflows/ci-plugin-catalog.yaml" - ".github/workflows/ci-rust-python-package.yaml" - ".github/workflows/release-rust-python-package.yaml" + - ".github/workflows/ci-python-package.yaml" + - ".github/workflows/release-python-package.yaml" concurrency: group: ci-plugin-catalog-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} diff --git a/.github/workflows/ci-python-package.yaml b/.github/workflows/ci-python-package.yaml new file mode 100644 index 0000000..2c5de77 --- /dev/null +++ b/.github/workflows/ci-python-package.yaml @@ -0,0 +1,206 @@ +name: CI Python Package Plugins + +on: + push: + branches: [main] + paths: + - "Makefile" + - "pyproject.toml" + - "uv.lock" + - "plugins/python/**" + - "plugins/tests/**" + - "tools/**" + - "tests/**" + - ".github/workflows/ci-python-package.yaml" + - ".github/workflows/release-python-package.yaml" + pull_request: + branches: [main] + paths: + - "Makefile" + - "pyproject.toml" + - "uv.lock" + - "plugins/python/**" + - "plugins/tests/**" + - "tools/**" + - "tests/**" + - ".github/workflows/ci-python-package.yaml" + - ".github/workflows/release-python-package.yaml" + workflow_dispatch: + +concurrency: + group: ci-python-package-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + pull-requests: read + +jobs: + validate-and-detect: + runs-on: ubuntu-latest + outputs: + python_plugins: ${{ steps.detect.outputs.python_plugins }} + has_python_plugins: ${{ steps.detect.outputs.has_python_plugins }} + python_plugin_count: ${{ steps.detect.outputs.python_plugin_count }} + python_release_validation_tags: ${{ steps.detect.outputs.python_release_validation_tags }} + has_python_release_validation_tags: ${{ steps.detect.outputs.has_python_release_validation_tags }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - id: detect + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + selection="$(python3 tools/plugin_catalog.py ci-selection . diff "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + selection="$(python3 tools/plugin_catalog.py ci-selection . all '' '')" + elif [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]]; then + selection="$(python3 tools/plugin_catalog.py ci-selection . all '' '')" + else + selection="$(python3 tools/plugin_catalog.py ci-selection . diff "${{ github.event.before }}" "${{ github.sha }}")" + fi + + selection="$(printf '%s' "${selection}" | python3 tools/validate_ci_selection.py)" + python_plugins="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["python_plugins"]))')" + has_python_plugins="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_python_plugins"]).lower())')" + python_plugin_count="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["python_plugin_count"])')" + python_release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["python_release_validation_tags"]))')" + has_python_release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_python_release_validation_tags"]).lower())')" + { + echo "python_plugins=${python_plugins}" + echo "has_python_plugins=${has_python_plugins}" + echo "python_plugin_count=${python_plugin_count}" + echo "python_release_validation_tags=${python_release_validation_tags}" + echo "has_python_release_validation_tags=${has_python_release_validation_tags}" + } >> "$GITHUB_OUTPUT" + + build-test: + needs: validate-and-detect + if: needs.validate-and-detect.outputs.has_python_plugins == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + plugin: ${{ fromJson(needs.validate-and-detect.outputs.python_plugins) }} + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install uv + run: python -m pip install uv==0.9.30 + + - name: Sync plugin environment + working-directory: plugins/python/${{ matrix.plugin }} + run: make sync + + - name: Plugin CI build verification + if: matrix.os == 'ubuntu-latest' + working-directory: plugins/python/${{ matrix.plugin }} + run: make ci-build + + - name: Plugin CI verification + if: matrix.os != 'ubuntu-latest' + working-directory: plugins/python/${{ matrix.plugin }} + run: make ci + + release-validation: + if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_python_release_validation_tags == 'true' + needs: validate-and-detect + strategy: + fail-fast: false + matrix: + tag: ${{ fromJson(needs.validate-and-detect.outputs.python_release_validation_tags) }} + permissions: + contents: read + pull-requests: read + id-token: write + uses: ./.github/workflows/release-python-package.yaml + with: + tag: ${{ matrix.tag }} + repository: testpypi + publish_enabled: false + + create-release-tags: + needs: + - validate-and-detect + - build-test + - release-validation + if: >- + ${{ + always() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.validate-and-detect.outputs.has_python_release_validation_tags == 'true' && + needs.build-test.result == 'success' && + (needs.release-validation.result == 'success' || needs.release-validation.result == 'skipped') + }} + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Create release tags for bumped plugin versions + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAGS: ${{ needs.validate-and-detect.outputs.python_release_validation_tags }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + while IFS= read -r tag; do + if [[ ! "${tag}" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "ERROR: unexpected characters in tag '${tag}'; aborting." >&2 + exit 1 + fi + if remote_sha="$(git ls-remote --exit-code --tags origin "refs/tags/${tag}" | awk '{print $1}')"; then + if [[ "${remote_sha}" != "${GITHUB_SHA}" ]]; then + echo "FATAL: tag '${tag}' already exists at ${remote_sha}, expected ${GITHUB_SHA}." >&2 + exit 1 + fi + echo "Tag ${tag} already exists remotely at ${GITHUB_SHA}; skipping tag creation." + else + if git show-ref --verify --quiet "refs/tags/${tag}"; then + local_sha="$(git rev-list -n 1 "${tag}")" + if [[ "${local_sha}" != "${GITHUB_SHA}" ]]; then + echo "FATAL: local tag '${tag}' exists at ${local_sha}, expected ${GITHUB_SHA}." >&2 + exit 1 + fi + else + git tag "${tag}" "${GITHUB_SHA}" + fi + git push origin "refs/tags/${tag}" || { + if remote_sha="$(git ls-remote --exit-code --tags origin "refs/tags/${tag}" | awk '{print $1}')" && + [[ "${remote_sha}" == "${GITHUB_SHA}" ]]; then + echo "Tag ${tag} already exists remotely at ${GITHUB_SHA}; skipping." + else + echo "FATAL: push of '${tag}' failed." >&2 + exit 1 + fi + } + fi + gh workflow run release-python-package.yaml \ + --ref "${tag}" \ + -f "tag=${tag}" \ + -f repository=pypi \ + -f publish_enabled=true + release_run_url="$(gh run list -w release-python-package.yaml --limit 1 --json url --jq '.[0].url')" + echo "Dispatched PyPI release workflow for ${tag}: ${release_run_url}" + done < <(python3 -c 'import json, os; [print(tag) for tag in json.loads(os.environ["RELEASE_TAGS"])]') diff --git a/.github/workflows/ci-rust-python-package.yaml b/.github/workflows/ci-rust-python-package.yaml index b47a967..f637fd3 100644 --- a/.github/workflows/ci-rust-python-package.yaml +++ b/.github/workflows/ci-rust-python-package.yaml @@ -65,6 +65,11 @@ jobs: has_mutation_cargo_packages: ${{ steps.detect.outputs.has_mutation_cargo_packages }} release_validation_tags: ${{ steps.detect.outputs.release_validation_tags }} has_release_validation_tags: ${{ steps.detect.outputs.has_release_validation_tags }} + rust_plugins: ${{ steps.detect.outputs.rust_plugins }} + has_rust_plugins: ${{ steps.detect.outputs.has_rust_plugins }} + rust_plugin_count: ${{ steps.detect.outputs.rust_plugin_count }} + rust_release_validation_tags: ${{ steps.detect.outputs.rust_release_validation_tags }} + has_rust_release_validation_tags: ${{ steps.detect.outputs.has_rust_release_validation_tags }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -98,6 +103,11 @@ jobs: has_mutation_cargo_packages="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_mutation_cargo_packages"]).lower())')" release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["release_validation_tags"]))')" has_release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_release_validation_tags"]).lower())')" + rust_plugins="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["rust_plugins"]))')" + has_rust_plugins="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_rust_plugins"]).lower())')" + rust_plugin_count="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["rust_plugin_count"])')" + rust_release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["rust_release_validation_tags"]))')" + has_rust_release_validation_tags="$(printf '%s' "${selection}" | python3 -c 'import json, sys; print(str(json.load(sys.stdin)["has_rust_release_validation_tags"]).lower())')" if [[ "${has_plugins}" == "false" ]]; then has_plugins_output="false" else @@ -114,16 +124,21 @@ jobs: echo "has_mutation_cargo_packages=${has_mutation_cargo_packages_output}" echo "release_validation_tags=${release_validation_tags}" echo "has_release_validation_tags=${has_release_validation_tags}" + echo "rust_plugins=${rust_plugins}" + echo "has_rust_plugins=${has_rust_plugins}" + echo "rust_plugin_count=${rust_plugin_count}" + echo "rust_release_validation_tags=${rust_release_validation_tags}" + echo "has_rust_release_validation_tags=${has_rust_release_validation_tags}" } >> "$GITHUB_OUTPUT" build-test: needs: validate-and-detect - if: needs.validate-and-detect.outputs.has_plugins == 'true' + if: needs.validate-and-detect.outputs.has_rust_plugins == 'true' strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - plugin: ${{ fromJson(needs.validate-and-detect.outputs.plugins) }} + plugin: ${{ fromJson(needs.validate-and-detect.outputs.rust_plugins) }} runs-on: ${{ matrix.os }} defaults: run: @@ -264,7 +279,7 @@ jobs: coverage: needs: validate-and-detect - if: needs.validate-and-detect.outputs.has_plugins == 'true' + if: needs.validate-and-detect.outputs.has_rust_plugins == 'true' runs-on: ubuntu-latest defaults: run: @@ -305,7 +320,7 @@ jobs: env: CARGO_PACKAGES: ${{ needs.validate-and-detect.outputs.cargo_packages }} NEXTEST_PROFILE: ci - PLUGINS: ${{ needs.validate-and-detect.outputs.plugins }} + PLUGINS: ${{ needs.validate-and-detect.outputs.rust_plugins }} PYO3_PYTHON: python run: | mkdir -p coverage @@ -333,7 +348,7 @@ jobs: - name: Enforce per-plugin coverage floor env: - PLUGINS: ${{ needs.validate-and-detect.outputs.plugins }} + PLUGINS: ${{ needs.validate-and-detect.outputs.rust_plugins }} run: python3 tools/plugin_catalog.py coverage-check . coverage/cobertura.xml 90.00 "${PLUGINS}" - name: Upload coverage to Codecov @@ -370,12 +385,12 @@ jobs: cargo doc "${cargo_args[@]}" --lib --no-deps --document-private-items release-validation: - if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_release_validation_tags == 'true' + if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_rust_release_validation_tags == 'true' needs: validate-and-detect strategy: fail-fast: false matrix: - tag: ${{ fromJson(needs.validate-and-detect.outputs.release_validation_tags) }} + tag: ${{ fromJson(needs.validate-and-detect.outputs.rust_release_validation_tags) }} permissions: contents: read pull-requests: read @@ -400,7 +415,7 @@ jobs: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && - needs.validate-and-detect.outputs.has_release_validation_tags == 'true' && + needs.validate-and-detect.outputs.has_rust_release_validation_tags == 'true' && needs.build-test.result == 'success' && needs.security-policy.result == 'success' && needs.coverage.result == 'success' && @@ -420,7 +435,7 @@ jobs: - name: Create release tags for bumped plugin versions env: GH_TOKEN: ${{ github.token }} - RELEASE_TAGS: ${{ needs.validate-and-detect.outputs.release_validation_tags }} + RELEASE_TAGS: ${{ needs.validate-and-detect.outputs.rust_release_validation_tags }} run: | set -euo pipefail git config user.name "github-actions[bot]" diff --git a/.github/workflows/plugin-maintenance.yaml b/.github/workflows/plugin-maintenance.yaml index 510ae54..26db53a 100644 --- a/.github/workflows/plugin-maintenance.yaml +++ b/.github/workflows/plugin-maintenance.yaml @@ -62,7 +62,7 @@ jobs: shell: bash run: | set -euo pipefail - plugins=( + rust_plugins=( encoded_exfil_detection pii_filter rate_limiter @@ -71,7 +71,7 @@ jobs: sql_sanitizer url_reputation ) - for plugin in "${plugins[@]}"; do + for plugin in "${rust_plugins[@]}"; do echo "::group::Testing ${plugin}" pushd "plugins/rust/python-package/${plugin}" make sync @@ -79,6 +79,17 @@ jobs: popd echo "::endgroup::" done + python_plugins=( + ica_metering_exporter + ) + for plugin in "${python_plugins[@]}"; do + echo "::group::Testing ${plugin}" + pushd "plugins/python/${plugin}" + make sync + make ci + popd + echo "::endgroup::" + done # --- Open PR if lock files changed --- @@ -95,10 +106,9 @@ jobs: ### Changes - `cargo update` applied to Rust workspace (`Cargo.lock`) - `uv lock --upgrade` applied to root Python workspace (`uv.lock`) - - `uv lock --upgrade` applied to `sql_sanitizer` plugin lock file ### Validation performed in CI - - All 7 plugins built and tested on `ubuntu-latest` + - All 8 managed plugins built and tested on `ubuntu-latest` - `cargo deny check advisories` passed — no new CVEs ### Reviewer checklist diff --git a/.github/workflows/release-python-package.yaml b/.github/workflows/release-python-package.yaml new file mode 100644 index 0000000..f5f92ae --- /dev/null +++ b/.github/workflows/release-python-package.yaml @@ -0,0 +1,295 @@ +name: Release Python Package Plugin + +on: + push: + tags: + - "*-v*" + workflow_call: + inputs: + tag: + description: "Release tag in the form -v" + required: true + type: string + repository: + description: "Target package repository" + required: true + type: string + publish_enabled: + description: "Whether to run the publish job" + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + tag: + description: "Release tag in the form -v" + required: true + type: string + repository: + description: "Target package repository" + required: true + default: testpypi + type: choice + options: + - testpypi + - pypi + publish_enabled: + description: "Whether to run the publish job" + required: false + default: true + type: boolean + +permissions: + contents: read + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + plugin: ${{ steps.resolve.outputs.plugin }} + slug: ${{ steps.resolve.outputs.plugin }} + plugin_path: ${{ steps.resolve.outputs.plugin_path }} + publish_env: ${{ steps.resolve.outputs.publish_env }} + publish_enabled: ${{ steps.resolve.outputs.publish_enabled }} + checkout_ref: ${{ steps.resolve.outputs.checkout_ref }} + tag_on_main: ${{ steps.resolve.outputs.tag_on_main }} + skip: ${{ steps.resolve.outputs.skip }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Validate plugin catalog + run: python3 tools/plugin_catalog.py validate . + + - id: resolve + shell: bash + env: + TAG_INPUT: ${{ inputs.tag }} + REPOSITORY_INPUT: ${{ inputs.repository }} + PUBLISH_ENABLED: ${{ inputs.publish_enabled }} + run: | + set -euo pipefail + git fetch --force origin "refs/heads/main:refs/remotes/origin/main" + if [[ -n "${TAG_INPUT}" ]]; then + tag="${TAG_INPUT}" + repository="${REPOSITORY_INPUT}" + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}" + git show-ref --verify --quiet "refs/tags/${tag}" + checkout_ref="refs/tags/${tag}" + tag_ref="refs/tags/${tag}" + elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" && "${PUBLISH_ENABLED}" == "false" ]]; then + checkout_ref="${GITHUB_SHA}" + tag_ref="${GITHUB_SHA}" + else + echo "Release tag ${tag} does not exist" >&2 + exit 1 + fi + else + tag="${GITHUB_REF_NAME}" + repository="pypi" + checkout_ref="${GITHUB_REF}" + tag_ref="${GITHUB_REF}" + fi + + if git merge-base --is-ancestor "${tag_ref}" "refs/remotes/origin/main"; then + tag_on_main=true + else + tag_on_main=false + fi + + release_info="$(python3 tools/plugin_catalog.py release-info . "${tag}")" + plugin="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["slug"])')" + plugin_path="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["path"])')" + language="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["language"])')" + if [[ "${language}" != "python" ]]; then + skip=true + else + skip=false + fi + + { + echo "plugin=${plugin}" + echo "plugin_path=${plugin_path}" + echo "checkout_ref=${checkout_ref}" + echo "tag_on_main=${tag_on_main}" + if [[ "${skip}" == "true" ]]; then + echo "skip=true" + else + echo "skip=false" + fi + if [[ "${PUBLISH_ENABLED}" == "false" ]]; then + echo "publish_enabled=false" + else + echo "publish_enabled=true" + fi + if [[ "${repository}" == "testpypi" ]]; then + echo "publish_env=testpypi" + else + echo "publish_env=pypi" + fi + } >> "$GITHUB_OUTPUT" + + build: + needs: resolve + if: needs.resolve.outputs.skip != 'true' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve.outputs.checkout_ref }} + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install uv + run: python -m pip install uv==0.9.30 + + - name: Build distributions + working-directory: ${{ needs.resolve.outputs.plugin_path }} + run: uv build --out-dir dist + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-dist + path: ${{ needs.resolve.outputs.plugin_path }}/dist/ + if-no-files-found: error + + test-built-wheel: + needs: [resolve, build] + if: needs.resolve.outputs.skip != 'true' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve.outputs.checkout_ref }} + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install uv + run: python -m pip install uv==0.9.30 + + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + path: ${{ needs.resolve.outputs.plugin_path }}/dist + + - name: Test built wheel in isolated virtualenv + working-directory: ${{ needs.resolve.outputs.plugin_path }} + run: | + tmpdir="$(mktemp -d)" + python -m venv "${tmpdir}/venv" + venv_python="${tmpdir}/venv/bin/python" + if [[ ! -f "${venv_python}" ]]; then + venv_python="${tmpdir}/venv/Scripts/python.exe" + fi + uv pip install --python "${venv_python}" --group dev PyYAML + "${venv_python}" -m pip install dist/*.whl + if [[ -d "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" ]]; then + mkdir -p "${tmpdir}/tests" + cp -R "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" "${tmpdir}/tests/${{ needs.resolve.outputs.slug }}" + cp "${GITHUB_WORKSPACE}/plugins/tests/conftest.py" "${tmpdir}/tests/conftest.py" + cp "${GITHUB_WORKSPACE}/plugins/tests/plugin_hooks.py" "${tmpdir}/tests/plugin_hooks.py" + cp "${GITHUB_WORKSPACE}/plugins/tests/real_cpex_imports.py" "${tmpdir}/tests/real_cpex_imports.py" + cp "${GITHUB_WORKSPACE}/plugins/tests/pytest.ini" "${tmpdir}/pytest.ini" + cd "${tmpdir}" + export CPEX_TEST_PLUGIN_HOOKS=1 + export PYTHONPATH="${tmpdir}/tests" + "${venv_python}" -m pytest \ + -c "${tmpdir}/pytest.ini" \ + "${tmpdir}/tests/${{ needs.resolve.outputs.slug }}" -v + fi + + test-built-sdist: + needs: [resolve, build] + if: needs.resolve.outputs.skip != 'true' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve.outputs.checkout_ref }} + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install uv + run: python -m pip install uv==0.9.30 + + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + path: ${{ needs.resolve.outputs.plugin_path }}/dist + + - name: Test built sdist in isolated virtualenv + working-directory: ${{ needs.resolve.outputs.plugin_path }} + run: | + tmpdir="$(mktemp -d)" + python -m venv "${tmpdir}/venv" + venv_python="${tmpdir}/venv/bin/python" + if [[ ! -f "${venv_python}" ]]; then + venv_python="${tmpdir}/venv/Scripts/python.exe" + fi + uv pip install --python "${venv_python}" --group dev PyYAML + "${venv_python}" -m pip install dist/*.tar.gz + if [[ -d "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" ]]; then + mkdir -p "${tmpdir}/tests" + cp -R "${GITHUB_WORKSPACE}/plugins/tests/${{ needs.resolve.outputs.slug }}" "${tmpdir}/tests/${{ needs.resolve.outputs.slug }}" + cp "${GITHUB_WORKSPACE}/plugins/tests/conftest.py" "${tmpdir}/tests/conftest.py" + cp "${GITHUB_WORKSPACE}/plugins/tests/plugin_hooks.py" "${tmpdir}/tests/plugin_hooks.py" + cp "${GITHUB_WORKSPACE}/plugins/tests/real_cpex_imports.py" "${tmpdir}/tests/real_cpex_imports.py" + cp "${GITHUB_WORKSPACE}/plugins/tests/pytest.ini" "${tmpdir}/pytest.ini" + cd "${tmpdir}" + export CPEX_TEST_PLUGIN_HOOKS=1 + export PYTHONPATH="${tmpdir}/tests" + "${venv_python}" -m pytest \ + -c "${tmpdir}/pytest.ini" \ + "${tmpdir}/tests/${{ needs.resolve.outputs.slug }}" -v + fi + + publish: + if: ${{ needs.resolve.outputs.skip != 'true' && needs.resolve.outputs.publish_enabled == 'true' && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }} + needs: [resolve, build, test-built-wheel, test-built-sdist] + runs-on: ubuntu-latest + environment: ${{ needs.resolve.outputs.publish_env }} + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + path: dist + + - name: Publish distributions to TestPyPI + if: needs.resolve.outputs.publish_env == 'testpypi' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + - name: Publish distributions to PyPI + if: needs.resolve.outputs.publish_env == 'pypi' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ + skip-existing: true diff --git a/.github/workflows/release-rust-python-package.yaml b/.github/workflows/release-rust-python-package.yaml index 716623d..cc9a768 100644 --- a/.github/workflows/release-rust-python-package.yaml +++ b/.github/workflows/release-rust-python-package.yaml @@ -53,6 +53,7 @@ jobs: publish_enabled: ${{ steps.resolve.outputs.publish_enabled }} checkout_ref: ${{ steps.resolve.outputs.checkout_ref }} tag_on_main: ${{ steps.resolve.outputs.tag_on_main }} + skip: ${{ steps.resolve.outputs.skip }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -103,9 +104,15 @@ jobs: release_info="$(python3 tools/plugin_catalog.py release-info . "${tag}")" plugin="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["slug"])')" plugin_path="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["path"])')" - if [[ -n "${TAG_INPUT}" ]]; then + language="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["language"])')" + if [[ "${language}" != "rust" ]]; then + skip=true + wheel_matrix="[]" + elif [[ -n "${TAG_INPUT}" ]]; then + skip=false wheel_matrix="$(python3 -c 'import json; print(json.dumps([{"runner":"ubuntu-latest","platform":"linux-x86_64"},{"runner":"ubuntu-24.04-arm","platform":"linux-aarch64"},{"runner":"ubuntu-24.04-s390x","platform":"linux-s390x"},{"runner":"ubuntu-24.04-ppc64le","platform":"linux-ppc64le"},{"runner":"macos-latest","platform":"macos-arm64"},{"runner":"windows-latest","platform":"windows-x86_64"}]))')" else + skip=false wheel_matrix="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["release_wheel_matrix"]))')" fi @@ -115,6 +122,11 @@ jobs: echo "wheel_matrix=${wheel_matrix}" echo "checkout_ref=${checkout_ref}" echo "tag_on_main=${tag_on_main}" + if [[ "${skip}" == "true" ]]; then + echo "skip=true" + else + echo "skip=false" + fi if [[ "${PUBLISH_ENABLED}" == "false" ]]; then echo "publish_enabled=false" else @@ -129,6 +141,7 @@ jobs: preflight: needs: resolve + if: needs.resolve.outputs.skip != 'true' runs-on: ubuntu-latest defaults: run: @@ -153,6 +166,7 @@ jobs: build-wheel: needs: [resolve, preflight] + if: needs.resolve.outputs.skip != 'true' strategy: fail-fast: false matrix: @@ -232,6 +246,7 @@ jobs: build-sdist: needs: [resolve, preflight] + if: needs.resolve.outputs.skip != 'true' runs-on: ubuntu-latest defaults: run: @@ -285,7 +300,7 @@ jobs: fi publish: - if: ${{ needs.resolve.outputs.publish_enabled == 'true' && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }} + if: ${{ needs.resolve.outputs.skip != 'true' && needs.resolve.outputs.publish_enabled == 'true' && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }} needs: [resolve, build-wheel, build-sdist] runs-on: ubuntu-latest environment: ${{ needs.resolve.outputs.publish_env }} diff --git a/.gitignore b/.gitignore index 7a84c89..fc4e372 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ dist/ build/ *.whl .venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ # Rust target/ diff --git a/.secrets.baseline b/.secrets.baseline index 3a36467..a3f844c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(^\\.secrets\\.baseline$|package-lock\\.json$|Cargo\\.lock$|uv\\.lock$|go\\.sum$|poetry\\.lock$|Pipfile\\.lock$)|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-07-21T09:27:16Z", + "generated_at": "2026-08-26T13:04:23Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -87,6 +87,16 @@ "verified_result": null } ], + "plugins/python/ica_metering_exporter/tests/test_ica_metering_exporter.py": [ + { + "hashed_secret": "90760303f7676d3943a5ba49ccccd6fc665ad05d", + "is_secret": false, + "is_verified": false, + "line_number": 693, + "type": "Secret Keyword", + "verified_result": null + } + ], "plugins/rust/python-package/encoded_exfil_detection/src/lib.rs": [ { "hashed_secret": "1d278d3c888d1a2fa7eed622bfc02927ce4049af", @@ -160,7 +170,7 @@ "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", "is_secret": false, "is_verified": false, - "line_number": 586, + "line_number": 1011, "type": "AWS Access Key", "verified_result": null } @@ -222,7 +232,7 @@ "hashed_secret": "55d2534ed6ad4f269b428160428fa2f6f541ba7b", "is_secret": false, "is_verified": false, - "line_number": 147, + "line_number": 153, "type": "Base64 High Entropy String", "verified_result": null }, @@ -230,7 +240,7 @@ "hashed_secret": "cf743b3a58a4d0f91c1d7f5825c0b1b5f7758174", "is_secret": false, "is_verified": false, - "line_number": 549, + "line_number": 572, "type": "Base64 High Entropy String", "verified_result": null }, @@ -238,7 +248,7 @@ "hashed_secret": "8e42b03e460b2cf358ffbcf4da3bc5d14a22c86e", "is_secret": false, "is_verified": false, - "line_number": 588, + "line_number": 611, "type": "Base64 High Entropy String", "verified_result": null }, @@ -246,7 +256,7 @@ "hashed_secret": "2093dd9cf307518cfe1d2fa5a3985d6fec4e995e", "is_secret": false, "is_verified": false, - "line_number": 601, + "line_number": 624, "type": "Base64 High Entropy String", "verified_result": null }, @@ -254,7 +264,7 @@ "hashed_secret": "caa924f200b35ceb6f0e33878faff75203bdccb4", "is_secret": false, "is_verified": false, - "line_number": 956, + "line_number": 999, "type": "Secret Keyword", "verified_result": null }, @@ -262,7 +272,17 @@ "hashed_secret": "f16da2820437f3c703ff5b95c813f310ce8e67a4", "is_secret": false, "is_verified": false, - "line_number": 1159, + "line_number": 1202, + "type": "Secret Keyword", + "verified_result": null + } + ], + "plugins/tests/ica_metering_exporter/test_observability_contract.py": [ + { + "hashed_secret": "ffa73a92066a0b09f6463a3d9d1247215ddca473", + "is_secret": false, + "is_verified": false, + "line_number": 40, "type": "Secret Keyword", "verified_result": null } @@ -272,7 +292,7 @@ "hashed_secret": "fc2398a73dd54d6237c4fdb58fd7d75347cf5af3", "is_secret": false, "is_verified": false, - "line_number": 529, + "line_number": 527, "type": "Secret Keyword", "verified_result": null } @@ -320,7 +340,7 @@ "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", "is_secret": false, "is_verified": false, - "line_number": 230, + "line_number": 285, "type": "AWS Access Key", "verified_result": null } diff --git a/AGENTS.md b/AGENTS.md index dd51770..65aae40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Extensibility (CPEX) Framework. Each plugin lives in its own top-level directory with independent build configuration. - Plugins are implemented as **pure Python** or **pure Rust**. Each plugin uses one language for its core logic — there is no dual-path where a plugin ships both Rust and Python implementations with a Rust fallback. For Rust plugins, Python entry points (PyO3/maturin) are a packaging and distribution layer only, not a parallel implementation. -- Each plugin has its own `pyproject.toml`, `Cargo.toml`, `Makefile`, and `tests/`. +- Rust plugins live in `plugins/rust/python-package//`; realized pure-Python plugins live in `plugins/python//`. +- Every plugin has its own `pyproject.toml`, `Makefile`, package directory, and unit tests. Rust plugins additionally have `Cargo.toml` and Rust source files. - Package names follow the pattern `cpex-` (e.g., `cpex-rate-limiter`). - `cpex` is the framework runtime dependency; declare it in plugin `pyproject.toml`. @@ -25,10 +26,10 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Ext - Run during plugin development and CI - Scope: Plugin logic, Rust functions, Python bindings -- **Plugin-framework integration tests**: Located in `plugins/rust/python-package//tests/` - - Test plugin integration with the local plugin framework (PyO3 bindings, Python ↔ Rust interface) +- **Plugin-framework integration tests**: Located in `plugins/rust/python-package//tests/` for Rust plugins and `plugins/tests//` for pure-Python plugins + - Test plugin integration with the local plugin framework, including the PyO3 interface for Rust plugins - Run via `make test-integration` within the plugin directory - - Scope: PyO3 entry points, plugin loading by the Python framework, hook dispatch + - Scope: Plugin entry points (including PyO3 for Rust), loading by the Python framework, hook dispatch - **Gateway integration tests**: Located in `mcp-context-forge/tests/integration/` - Test plugin integration with the full gateway @@ -46,7 +47,7 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Ext When developing a plugin: -1. Write unit tests in the plugin's own directory (Rust: inline `mod tests`; Python: `plugins/rust/python-package//tests/`) and plugin-framework integration tests in `plugins/rust/python-package//tests/` +1. Write unit tests in the plugin's own directory (Rust: inline `mod tests` plus binding tests; pure Python: `plugins/python//tests/`) and plugin-framework integration tests in the Rust plugin's `tests/` directory or `plugins/tests//` for pure Python 2. Run local tests: `make test-all` and `make test-integration` from plugin directory 3. After plugin PR is merged, coordinate with `mcp-context-forge` team 4. Write gateway integration/E2E tests in `mcp-context-forge/tests/` @@ -112,6 +113,22 @@ The plugin framework is currently implemented in Python (`mcpgateway/plugins/fra - Update mcp-context-forge dependencies - Deploy with new plugin version +### Current Workflow: Pure Python + +Pure-Python plugins implement their logic directly in Python under `plugins/python//`; they are independent implementations, not fallbacks for Rust plugins. + +1. Create the plugin directory and required package files under `plugins/python//`. +2. Implement the plugin in `cpex_/` and keep `plugin-manifest.yaml` aligned with the package entry point. +3. Add unit tests under `plugins/python//tests/` and plugin-framework integration tests under `plugins/tests//`. +4. Run the local workflow: + ```bash + cd plugins/python/ + uv sync --dev + make test-all + make test-integration + ``` +5. Run `make ci`, then release with the standard `-v` tag after review. + ### Future Workflow: Pure Rust **Architecture (Post-Framework Migration):** @@ -175,7 +192,7 @@ The plugin framework is currently implemented in Python (`mcpgateway/plugins/fra ## Build & Test -From within a plugin directory (e.g., `rate_limiter/`): +From within a Rust plugin directory (e.g., `rate_limiter/`): ```bash uv sync --dev # Install Python dependencies @@ -184,24 +201,31 @@ make test-all # Run Rust + Python tests make check-all # fmt-check + clippy + Rust tests ``` +From within a pure-Python plugin directory: + +```bash +uv sync --dev +make test-all # Run unit and plugin-framework integration tests +make check-all # Run formatting, lint, and type checks +``` + ## Conventions - Python: 3.11+, type hints, snake_case, Pydantic for config validation. - Rust: stable toolchain, `cargo fmt`, `clippy -- -D warnings`. - All source files must include Apache-2.0 SPDX license headers. -- Versions are defined in `Cargo.toml` and pulled dynamically by maturin (`dynamic = ["version"]`). +- Rust versions are defined in `Cargo.toml` and pulled dynamically by maturin (`dynamic = ["version"]`); pure-Python versions are defined in the plugin's `pyproject.toml`. ## Versioning Every change to a core plugin must include a plugin version bump. -When bumping a plugin version, update all of these: +The version source and lockfile depend on the implementation language: -1. `Cargo.toml` — the single source of truth for the version number. -2. `cpex_/plugin-manifest.yaml` — the `version` field. -3. `Cargo.lock` — updates automatically on the next build. +- **Rust**: `Cargo.toml` is the single source of truth; update `Cargo.lock` and make the `cpex_/plugin-manifest.yaml` version match. +- **Pure Python**: the plugin's `pyproject.toml` is the single source of truth; regenerate the root `uv.lock` and make the `cpex_/plugin-manifest.yaml` version match. Pure-Python workspace members do not have member-local `uv.lock` files. -Tag releases as `-v` (e.g., `rate-limiter-v0.0.2`) on `main` to trigger the PyPI publish workflow. +Tag releases as `-v` on `main` to trigger the language-appropriate PyPI publish workflow. Examples are `rate-limiter-v0.0.2` and `ica-metering-exporter-v0.1.0`. ## OpenTelemetry Integration and Trace Context @@ -245,4 +269,4 @@ Note: `trace_id` is an input only (read from `extensions.request.trace_id`) and 2. Read `trace_id` from `extensions` when available. 3. Emit metrics to `result.metadata[]` only when a valid `trace_id` is present. 4. Ensure all emitted data is non-sensitive and aggregated (counts, not individual values). -5. Document the metadata keys and values in your plugin's README. \ No newline at end of file +5. Document the metadata keys and values in your plugin's README. diff --git a/DEVELOPING.md b/DEVELOPING.md index 9f26084..41acb79 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -2,24 +2,24 @@ ## Repository Model -This repository currently manages one plugin class: Rust plugins that are built with PyO3/maturin and published to PyPI as Python packages. +This repository manages Rust plugins built with PyO3/maturin and pure-Python plugins. Both are published to PyPI as Python packages, but each plugin has only one implementation language. -Managed plugin path: +Managed plugin paths: ```text -plugins/rust/python-package// +plugins/rust/python-package// # Rust implementation with Python packaging +plugins/python// # Pure-Python implementation ``` -Every managed plugin must satisfy the catalog contract enforced by `tools/plugin_catalog.py`: +`tools/plugin_catalog.py` discovers both roots and records each plugin's language. Every managed plugin must satisfy these shared catalog contracts: - distribution name: `cpex-` - Python module: `cpex_` -- `Cargo.toml` is the version source of truth -- `cpex_/plugin-manifest.yaml` version matches `Cargo.toml` - `cpex_/plugin-manifest.yaml` defines top-level `kind` in `module.object` form - `pyproject.toml` publishes the matching plugin class reference under `[project.entry-points."cpex.plugins"]` in `module:object` form -- plugin `Cargo.toml` repository metadata points to `https://github.com/IBM/cpex-plugins` -- plugin crate is listed in the top-level workspace `Cargo.toml` +- the manifest version matches the language-specific source: `Cargo.toml` for Rust or `pyproject.toml` for pure Python +- every plugin is a root uv-workspace member; only Rust plugin crates are top-level Cargo-workspace members +- Rust plugin `Cargo.toml` repository metadata points to `https://github.com/IBM/cpex-plugins` ## Working on One Plugin @@ -30,7 +30,18 @@ make install make test-all ``` -Swap `rate_limiter` for any other managed plugin slug. +Swap `rate_limiter` for any other managed Rust plugin slug. + +For a pure-Python plugin: + +```bash +cd plugins/python/ica_metering_exporter +make sync +make test-all +make check-all +``` + +Pure-Python unit tests run from `plugins/python//tests/`; `make test-integration` runs the matching framework suite from `plugins/tests//`. ## Secrets Detection Count Semantics @@ -79,9 +90,9 @@ make detect-secrets-check ## Adding a New Managed Plugin -### Using the Plugin Scaffold Generator (Recommended) +### Using the Rust Plugin Scaffold Generator -The easiest way to create a new plugin is using the scaffold generator: +**Rust-only:** the scaffold generator creates Rust plugins with PyO3/maturin packaging. It must not be used to generate pure-Python plugins. ```bash make plugin-scaffold @@ -111,7 +122,7 @@ After scaffolding: 3. Run `make plugins-validate` to verify structure 4. Run `make plugin-test PLUGIN=` to execute the plugin's full `make ci` flow -### Manual Plugin Creation +### Manual Rust Plugin Creation If you prefer to create a plugin manually: @@ -121,6 +132,16 @@ If you prefer to create a plugin manually: 4. Run `make plugins-validate`. 5. Run `make plugin-test PLUGIN=` to execute the plugin's full `make ci` flow. +### Manual Pure-Python Plugin Creation + +1. Create `plugins/python//` with `pyproject.toml`, `Makefile`, `README.md`, `cpex_/`, and `tests/`; do not add a `Cargo.toml`. +2. Register the distribution as a root uv-workspace member and publish the manifest's plugin class under `[project.entry-points."cpex.plugins"]`. +3. Keep the version in the plugin's `pyproject.toml`, match it in `cpex_/plugin-manifest.yaml`, and regenerate the root `uv.lock`. +4. Add plugin-framework integration tests under `plugins/tests//`. +5. Run `make plugins-validate`, then run `make sync`, `make test-all`, and `make ci` from the plugin directory. + +The catalog exposes separate Rust and Python selections to CI. `.github/workflows/ci-rust-python-package.yaml` builds selected Rust plugins, while `.github/workflows/ci-python-package.yaml` builds selected pure-Python plugins. Shared changes can select plugins from both roots without treating a Python implementation as a Rust fallback. + ## Releasing Releases are per plugin and version-bump driven. Use this process to publish a @@ -128,14 +149,16 @@ new version of an existing managed plugin to PyPI. 1. Pick the plugin slug and new version. - The plugin slug is the directory name under - `plugins/rust/python-package//`, for example `rate_limiter`. The tag - slug is the hyphenated form, for example `rate-limiter`. + The plugin slug is the directory name under its managed root, for example + `plugins/rust/python-package/rate_limiter/` or + `plugins/python/ica_metering_exporter/`. The tag slug is the hyphenated + form, for example `rate-limiter` or `ica-metering-exporter`. 2. Update the version files. - `Cargo.toml` is the version source of truth. The plugin manifest and - top-level lockfile must stay consistent with it. + `Cargo.toml` is the Rust version source of truth and updates `Cargo.lock`. + A pure-Python plugin's `pyproject.toml` is its version source of truth and + updates the root `uv.lock`. The plugin manifest must match in both cases. ```bash $EDITOR plugins/rust/python-package/rate_limiter/Cargo.toml @@ -143,6 +166,9 @@ new version of an existing managed plugin to PyPI. cargo update -p rate_limiter --precise 0.0.5 ``` + For a pure-Python plugin, edit its `pyproject.toml` and manifest, then run + `uv lock` at the repository root. + 3. Run local validation. ```bash @@ -154,11 +180,12 @@ new version of an existing managed plugin to PyPI. 5. Let CI create the release tag and publish. - On a `main` push, `.github/workflows/ci-rust-python-package.yaml` detects - plugin `Cargo.toml` version bumps. After the build, security, coverage, and - documentation jobs are green, it creates the release tag at the merge commit - and invokes `.github/workflows/release-rust-python-package.yaml` with PyPI - publishing enabled. + On a `main` push, `.github/workflows/ci-rust-python-package.yaml` detects Rust + `Cargo.toml` version bumps and `.github/workflows/ci-python-package.yaml` + detects pure-Python `pyproject.toml` version bumps. After each language's + required checks are green, the matching CI workflow creates the release tag + at the merge commit and invokes `release-rust-python-package.yaml` or + `release-python-package.yaml` with PyPI publishing enabled. The workflow uses `GITHUB_TOKEN` to push release tags. Repository tag protection or rulesets for release tag patterns must allow that token, or @@ -175,6 +202,7 @@ new version of an existing managed plugin to PyPI. - `rate_limiter` -> `rate-limiter-v0.0.5` - `secrets_detection` -> `secrets-detection-v0.2.2` + - `ica_metering_exporter` -> `ica-metering-exporter-v0.1.0` Use `make plugins-list` to inspect the current managed plugin slugs and package names. Do not create the tag manually for ordinary releases; manual @@ -185,6 +213,8 @@ new version of an existing managed plugin to PyPI. ```bash gh run list --workflow ci-rust-python-package.yaml --branch main --limit 5 gh run list --workflow release-rust-python-package.yaml --limit 5 + gh run list --workflow ci-python-package.yaml --branch main --limit 5 + gh run list --workflow release-python-package.yaml --limit 5 gh run watch --exit-status ``` @@ -197,12 +227,13 @@ new version of an existing managed plugin to PyPI. The release page should also exist at `https://pypi.org/project/cpex-rate-limiter/0.0.5/`. -The CI workflow creates tags only after the required checks pass. It then calls -the release workflow directly for publishing; it does not rely on a bot-created -tag push to start another workflow run. The release workflow resolves the tag -back to the managed plugin path, validates metadata and versions, then builds -and publishes only that plugin. PyPI publishing is allowed only for release tags -that point at `main`. +The CI workflows create tags only after their required checks pass. They call +the matching release workflow directly for publishing rather than relying on a +bot-created tag push. Both release workflows resolve every catalog tag, then +language guards skip all post-resolution jobs in the wrong-language workflow. +The matching workflow validates metadata and versions, builds and tests the +plugin's artifacts, and publishes only that plugin. PyPI publishing is allowed +only for release tags that point at `main`. Dependency refresh work is separate from the release process. Track broader dependency or ContextForge updates outside a plugin release PR. diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 3860c54..4e9af8c 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -35,12 +35,12 @@ The monthly workflow runs automatically on the 1st of each month and opens a PR. 1. **Review the lock file diffs** - `Cargo.lock` — check for any crate jumping a major version unexpectedly - - `uv.lock` — check for any package bumping past the constraint bounds in `pyproject.toml` - - `plugins/rust/python-package/sql_sanitizer/uv.lock` — same check + - Root `uv.lock` — check every Rust-packaged and pure-Python workspace member for packages bumping past the constraint bounds in `pyproject.toml` 2. **Verify gateway compatibility constraints are still satisfied** - Open `pyproject.toml` → `[tool.uv] constraint-dependencies` - Confirm each pinned range (`cpex`, `pydantic`, `maturin`, etc.) still holds after the bump + - Review `httpx` and `PyJWT` compatibility for the ICA Metering Exporter; the root uv lock spans all workspace members 3. **Check `deny.toml` advisory suppressions** - If `cargo deny check advisories` introduced new RUSTSEC advisories in the workflow log, evaluate and either update the suppress list with a justification comment or fix the dependency @@ -62,14 +62,15 @@ When a release is imminent and a dependency bump is needed: # OR run locally: cargo update uv lock --upgrade -cd plugins/rust/python-package/sql_sanitizer && uv lock --upgrade && cd - cargo deny --all-features check advisories --config deny.toml -# Run tests for all plugins: -for plugin in encoded_exfil_detection pii_filter rate_limiter retry_with_backoff secrets_detection sql_sanitizer url_reputation; do - make plugin-test PLUGIN=$plugin +# Run tests for all plugins through dual-root routing: +for plugin in encoded_exfil_detection pii_filter rate_limiter retry_with_backoff secrets_detection sql_sanitizer url_reputation ica_metering_exporter; do + make plugin-test PLUGIN="$plugin" done ``` +The maintenance workflow keeps explicit `rust_plugins` and `python_plugins` arrays because their test commands run from different roots. The Python loop runs each slug from `plugins/python/${plugin}`. Add every future Rust slug to `rust_plugins` and every future pure-Python slug to `python_plugins` in `.github/workflows/plugin-maintenance.yaml`. + --- ## Gateway Compatibility Table @@ -82,6 +83,8 @@ Records known constraints between plugin dependencies and the gateway (`cpex`/`m | all | `pydantic` | `>=2.13.4,<3` | gateway 0.1.x | Pydantic v3 not yet validated against gateway models | 2026-07 | | all | `maturin` | `>=1.13.3,<2.0` | build toolchain | Major maturin bumps may change wheel ABI tagging | 2026-07 | | all | `redis` | `>=7.4.0` | gateway 0.1.x | Lower bound — no upper constraint yet | 2026-07 | +| `ica_metering_exporter` | `httpx` | `>=0.27,<1` | gateway 0.1.x | HTTP client is resolved in the root uv workspace; validate transport behavior when bumping | 2026-08 | +| `ica_metering_exporter` | `PyJWT` | `>=2.8,<3` | gateway 0.1.x | HS256 service JWT behavior is resolved in the root uv workspace; validate token generation when bumping | 2026-08 | | `pii_filter` | `pyo3` | `0.29.0` (workspace) | — | ABI3-py311; bump with cross-plugin coordination | 2026-07 | | `secrets_detection` | `regex` | `1.12.3` (workspace) | — | No constraint; update freely | 2026-07 | diff --git a/Makefile b/Makefile index 899f531..be3b0d8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ DETECT_SECRETS_SPEC := git+https://github.com/ibm/detect-secrets.git@076672a9a01abdfc7ecee2e7d14f08cdccb73976 +PLUGIN_DIR := $(firstword $(wildcard plugins/python/$(PLUGIN) plugins/rust/python-package/$(PLUGIN))) .PHONY: help plugins-list plugins-validate plugin-test plugin-mutants plugin-mutants-list plugin-scaffold plugin-scaffold-help detect-secrets-scan detect-secrets-audit detect-secrets-check @@ -26,7 +27,9 @@ detect-secrets-check: ## Verify no unaudited secrets (CI equivalent) plugin-test: @test -n "$(PLUGIN)" || (echo "Set PLUGIN=" && exit 1) - @cd plugins/rust/python-package/$(PLUGIN) && make sync && make ci + @case "$(PLUGIN)" in (*[!a-z0-9_]*|'') echo "Unknown plugin $(PLUGIN)"; exit 1;; esac + @test -n "$(PLUGIN_DIR)" || (echo "Unknown plugin $(PLUGIN)" && exit 1) + @cd $(PLUGIN_DIR) && make sync && make ci plugin-mutants: @test -n "$(PLUGIN)" || (echo "Set PLUGIN=" && exit 1) diff --git a/README.md b/README.md index 4c58a71..c5e265a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cpex-plugins -Monorepo for managed CPEX plugins that are implemented in Rust and published as Python packages. +Monorepo for managed CPEX plugins implemented in pure Python or Rust and published as Python packages. ## Runtime Requirements @@ -10,7 +10,10 @@ Rust plugin packages require their compiled PyO3 extension at import/runtime. Th ## Layout -Managed plugins live under `plugins/rust/python-package//`. +Managed plugins live under two language-specific roots: + +- Rust plugins packaged for Python: `plugins/rust/python-package//` +- Pure-Python plugins: `plugins/python//` Current plugins: @@ -21,31 +24,34 @@ Current plugins: | `rate_limiter` | `cpex-rate-limiter` | Enforce per-user, per-tenant, and per-tool rate limits | | `retry_with_backoff` | `cpex-retry-with-backoff` | Apply retry policy and exponential backoff metadata to transient failures | | `secrets_detection` | `cpex-secrets-detection` | Detect and redact likely credentials in prompt arguments, tool inputs and outputs, and resource content | +| `sql_sanitizer` | `cpex-sql-sanitizer` | Analyze SQL for blocked statements, unsafe mutations, and interpolation patterns | | `url_reputation` | `cpex-url-reputation` | Apply static URL allowlist, blocklist, pattern, and heuristic checks before resource fetches | +| `ica_metering_exporter` | `cpex-ica-metering-exporter` | Export MCP tool invocation metrics to an ICA core-services metering endpoint | -Each managed plugin must include: +Every managed plugin includes: - `pyproject.toml` -- `Cargo.toml` - `Makefile` - `README.md` - `cpex_/__init__.py` - `cpex_/plugin-manifest.yaml` -Python integration tests live under `plugins/tests//`; Rust unit tests live in the plugin crate. +Rust plugins additionally include `Cargo.toml` and Rust source files. Pure-Python plugins do not include `Cargo.toml`; their implementation and unit tests live in the Python package directory. + +Pure-Python unit tests live under `plugins/python//tests/`, shared plugin-framework integration tests live under `plugins/tests//`, and Rust unit tests live in the plugin crate. -Rust crates are owned by the top-level workspace in `Cargo.toml`. Python package names follow `cpex-`, Python modules follow `cpex_`, plugin manifests must declare a top-level `kind` in `module.object` form, and `pyproject.toml` must publish the matching `module:object` reference under `[project.entry-points."cpex.plugins"]`. Release tags use the hyphenated slug form `-v`, for example `rate-limiter-v0.0.2`. +Rust crates are owned by the top-level workspace in `Cargo.toml`; all Python distributions are members of the root uv workspace. Python package names follow `cpex-`, Python modules follow `cpex_`, plugin manifests must declare a top-level `kind` in `module.object` form, and `pyproject.toml` must publish the matching `module:object` reference under `[project.entry-points."cpex.plugins"]`. Rust plugin versions come from `Cargo.toml` and update `Cargo.lock`; pure-Python plugin versions come from `pyproject.toml` and update the root `uv.lock`. The plugin manifest version must match the language-specific source in both cases. Release tags use the hyphenated slug form `-v`, for example `rate-limiter-v0.0.2`. ## Testing Strategy Testing spans two repositories: -- **Unit tests**: within each plugin's own directory — Python in `plugins/rust/python-package//tests/`, Rust inline via `mod tests` in source files -- **Plugin-framework integration tests**: `plugins/rust/python-package//tests/` — test PyO3 bindings and plugin loading by the Python framework (`make test-integration`) +- **Unit tests**: within each plugin's own directory — pure Python in `plugins/python//tests/`, Rust inline via `mod tests`, and Python binding tests for Rust packages under their plugin directory +- **Plugin-framework integration tests**: `plugins/tests//` for pure-Python plugins and the Rust plugin's own `tests/` directory — test framework discovery, loading, and hook dispatch (`make test-integration`) - **Gateway integration tests**: `mcp-context-forge/tests/integration/` — test plugin integration with the full gateway - **E2E tests**: `mcp-context-forge/tests/e2e/` — test complete workflows with plugins -Unit tests and plugin-framework integration tests live in the plugin's own directory. Gateway integration and E2E tests live in `mcp-context-forge`. +Unit tests live in each plugin's own directory. Plugin-framework integration tests live under `plugins/tests//` for pure-Python plugins and in the plugin-local `tests/` directory for Rust plugins. Gateway integration and E2E tests live in `mcp-context-forge`. See [TESTING.md](TESTING.md) for detailed testing guidelines and cross-repository coordination. @@ -72,9 +78,9 @@ After the plugin framework is migrated to Rust: See [DEVELOPING.md](DEVELOPING.md) for detailed workflows for both current and future development. -## Creating a New Plugin +## Creating a New Rust Plugin -Use the plugin scaffold generator to create a new plugin with all required files and structure: +The current plugin scaffold generator is Rust-only. Use it to create a Rust plugin with its PyO3/maturin packaging layer; create pure-Python plugins manually under `plugins/python//`. ```bash make plugin-scaffold @@ -119,7 +125,7 @@ The catalog and validator used by CI live in `tools/plugin_catalog.py`. ## Quick Start -### Develop a Plugin +### Develop a Rust Plugin ```bash cd plugins/rust/python-package/ @@ -128,6 +134,14 @@ make install # Build Rust extension make test-all # Run unit tests ``` +### Develop a Pure-Python Plugin + +```bash +cd plugins/python/ +make sync # Install dependencies +make test-all # Run unit and plugin-framework integration tests +``` + ### Plugin-Framework Integration Testing After unit tests pass, run plugin-framework integration tests within `cpex-plugins`: diff --git a/TESTING.md b/TESTING.md index fb5d921..313fad5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -7,7 +7,8 @@ Testing spans two repositories. `cpex-plugins` owns unit tests and plugin-framew ### Unit Tests (cpex-plugins) **Location**: Within each plugin's own directory -- Python: `plugins/rust/python-package//tests/` (current hybrid) or `plugins/python//tests/` (pure Python) +- Pure Python: `plugins/python//tests/` +- Rust package binding tests: `plugins/rust/python-package//tests/` - Rust: inline `mod tests` within source files (e.g., `src/lib.rs`) **Scope**: @@ -25,19 +26,24 @@ Testing spans two repositories. `cpex-plugins` owns unit tests and plugin-framew **Run Locally**: ```bash +# Pure Python +cd plugins/python/ +make test-all # Runs unit and plugin-framework integration tests + +# Rust with Python packaging cd plugins/rust/python-package/ make test-all # Runs both Rust and Python unit tests ``` ### Plugin-Framework Integration Tests (cpex-plugins) -**Location**: `cpex-plugins/tests/` (plugin-specific `tests/` directories) +**Location**: `plugins/tests//` for pure-Python plugins; the plugin-local `tests/` directory for Rust plugins **Scope**: -- PyO3 entry points and Python ↔ Rust interface +- Python plugin entry points, including the PyO3 interface for Rust plugins - Plugin loading by the Python plugin framework - Hook dispatch through the framework layer -- Coverage of PyO3 paths (run as part of Rust coverage) +- Coverage of PyO3 paths for Rust plugins (run as part of Rust coverage) **Purpose**: - Validate that the Rust implementation is correctly exposed through PyO3 bindings @@ -46,10 +52,12 @@ make test-all # Runs both Rust and Python unit tests **Run Locally**: ```bash -cd plugins/rust/python-package/ +cd plugins/python/ # Or plugins/rust/python-package/ make test-integration # Runs plugin-framework integration tests ``` +The shared `plugins/tests/conftest.py` discovers selected package directories from both managed roots, checking `plugins/python/` first and then `plugins/rust/python-package/`. This Python-first lookup selects the realized package location for a slug; it does not provide a Python fallback implementation for a Rust plugin. + ### Gateway Integration Tests (mcp-context-forge) **Location**: `mcp-context-forge/tests/integration/` @@ -110,11 +118,11 @@ python3 tools/plugin_catalog.py validate . They verify: -- managed plugin location under `plugins/rust/python-package/` +- managed plugin location under `plugins/rust/python-package/` or `plugins/python/` - plugin manifests do not exist outside the managed root - required files and package/module naming -- workspace membership in the top-level `Cargo.toml` -- version consistency between `Cargo.toml` and `plugin-manifest.yaml` +- root uv-workspace membership for every plugin and top-level Cargo-workspace membership for Rust crates +- version consistency between the language-specific source (`Cargo.toml` or `pyproject.toml`) and `plugin-manifest.yaml` - manifest `kind` consistency (`module.object`) with `[project.entry-points."cpex.plugins"]` targets (`module:object`) - repository metadata consistency - changed-plugin detection for CI @@ -123,7 +131,7 @@ They verify: ### 2. Plugin Unit Tests -Each plugin has its own Rust and Python unit test suite. +Rust plugins have Rust and Python binding unit tests. Pure-Python plugins have a Python unit test suite. ```bash cd plugins/rust/python-package/rate_limiter @@ -145,7 +153,7 @@ make plugin-test PLUGIN=rate_limiter ### 3. Plugin-Framework Integration Tests -Each plugin also has integration tests between the plugin and the Python plugin framework. These live in the plugin's `tests/` directory alongside unit tests and test the PyO3 interface — ensuring the Rust implementation is correctly exposed through Python bindings and that the framework can discover, load, and invoke the plugin. +Each plugin also has integration tests between the plugin and the Python plugin framework. Pure-Python integration tests live under `plugins/tests//`; Rust integration tests live in the plugin's `tests/` directory alongside binding tests. They ensure the framework can discover, load, and invoke each plugin, and cover the PyO3 interface for Rust implementations. ```bash cd plugins/rust/python-package/rate_limiter @@ -166,9 +174,9 @@ cargo install cargo-llvm-cov --version 0.8.4 --locked cargo install cargo-nextest --version 0.9.133 --locked mkdir -p coverage CARGO_PACKAGES="$(python3 tools/plugin_catalog.py ci-selection-field . all '' '' cargo_packages)" -PLUGINS="$(python3 tools/plugin_catalog.py ci-selection-field . all '' '' plugins)" +RUST_PLUGINS="$(python3 tools/plugin_catalog.py ci-selection-field . all '' '' rust_plugins)" mapfile -t cargo_packages < <(python3 -c 'import json, os; [print(package) for package in json.loads(os.environ["CARGO_PACKAGES"])]') -mapfile -t plugins < <(python3 -c 'import json, os; [print(plugin) for plugin in json.loads(os.environ["PLUGINS"])]') +mapfile -t rust_plugins < <(python3 -c 'import json, os; [print(plugin) for plugin in json.loads(os.environ["RUST_PLUGINS"])]') cargo_args=() for package in "${cargo_packages[@]}"; do cargo_args+=("-p" "${package}") @@ -180,14 +188,14 @@ export CARGO_TARGET_DIR="${CARGO_LLVM_COV_TARGET_DIR}/llvm-cov-target" export CARGO_LLVM_COV_BUILD_DIR="${CARGO_TARGET_DIR}" export LLVM_PROFILE_FILE="${CARGO_TARGET_DIR}/cpex-plugins-%p-%10m.profraw" mkdir -p "${CARGO_TARGET_DIR}" -for plugin in "${plugins[@]}"; do +for plugin in "${rust_plugins[@]}"; do (cd "plugins/rust/python-package/${plugin}" && make sync && uv run maturin develop) done -for plugin in "${plugins[@]}"; do +for plugin in "${rust_plugins[@]}"; do (cd "plugins/rust/python-package/${plugin}" && make test-integration) done env -u CARGO_TARGET_DIR -u CARGO_LLVM_COV_BUILD_DIR -u CARGO_LLVM_COV_TARGET_DIR -u LLVM_PROFILE_FILE cargo llvm-cov report "${cargo_args[@]}" --cobertura --output-path coverage/cobertura.xml -python3 tools/plugin_catalog.py coverage-check . coverage/cobertura.xml 90.00 "${PLUGINS}" +python3 tools/plugin_catalog.py coverage-check . coverage/cobertura.xml 90.00 "${RUST_PLUGINS}" ``` Rust unit tests use `cargo nextest run`. Coverage uses `cargo llvm-cov nextest --no-report` for the Rust test phase, then runs pytest before generating the final report so PyO3 paths stay covered. CI uses the `ci` nextest profile, which disables fail-fast and prints failure output immediately and again at the end. Nextest does not run Rust doctests; this repo currently has no Rust doctest code blocks, so there is no separate doctest step. @@ -213,10 +221,10 @@ make plugin-mutants PLUGIN=retry_with_backoff 1. **Develop Plugin in cpex-plugins**: ```bash - cd cpex-plugins/plugins/rust/python-package/ + cd cpex-plugins/plugins/python/ # Or plugins/rust/python-package/ # Implement plugin logic - # Write unit tests in tests/ - # Write plugin-framework integration tests in tests/ + # Write unit tests in the plugin-local tests/ + # Write pure-Python integration tests in cpex-plugins/plugins/tests// make test-all # Run unit tests make test-integration # Run plugin-framework integration tests ``` @@ -293,6 +301,7 @@ make plugin-mutants PLUGIN=retry_with_backoff - Runs plugin unit tests - Runs plugin-framework integration tests (`make test-integration`) - Builds and packages plugins +- Uses separate `ci-python-package.yaml` and `ci-rust-python-package.yaml` workflows fed by language-specific catalog selections - On `main`, creates release tags for plugin version bumps only after required checks are green - Invokes the release workflow for PyPI publishing after tag creation @@ -324,18 +333,20 @@ make plugin-mutants PLUGIN=retry_with_backoff ## CI Behavior -Repo contract tests run in their own CI workflow. The Rust plugin CI workflow uses the same plugin catalog to select affected plugin build, integration, and coverage jobs. +Repo contract tests run in their own CI workflow. Separate pure-Python and Rust plugin CI workflows use the same dual-root catalog to select affected jobs; Rust-only integration and coverage behavior remains in the Rust workflow. Per-plugin build/test jobs are then scoped by the plugin catalog: - plugin-only changes run only the affected plugin jobs - shared workflow, workspace, root orchestration, docs, test, and tool changes run all managed plugin jobs -For pull requests with plugin version bumps, release validation builds the -target package with publishing disabled. On `main`, Rust plugin CI creates the -release tag after required checks are green, then calls release CI with PyPI -publishing enabled. Release CI validates the tag and plugin metadata before any -artifact is published. +For pull requests with plugin version bumps, each language-specific CI workflow +invokes its matching release workflow to build and test the target package with +publishing disabled. On `main`, each language-specific CI workflow creates tags +only after its required checks pass, then invokes its matching release workflow +with PyPI publishing enabled. Rust CI retains additional security, mutation, +coverage, and documentation gates. The matching release workflow validates the +tag and plugin metadata before any artifact is published. ## Testing Best Practices @@ -367,8 +378,8 @@ artifact is published. ```bash # In cpex-plugins -cd plugins/rust/python-package/ -make test-all # Run unit tests (Rust + Python) +cd plugins/python/ # Or plugins/rust/python-package/ +make test-all # Run language-appropriate unit tests make test-integration # Run plugin-framework integration tests # In mcp-context-forge diff --git a/plugins/python/ica_metering_exporter/Makefile b/plugins/python/ica_metering_exporter/Makefile new file mode 100644 index 0000000..91f07bf --- /dev/null +++ b/plugins/python/ica_metering_exporter/Makefile @@ -0,0 +1,32 @@ +.PHONY: sync test test-unit test-integration test-all check-all build install ci-build ci clean + +sync: + uv sync --dev + +test: test-unit + +test-unit: + uv run pytest tests/ -v + +test-integration: + CPEX_TEST_PLUGIN_HOOKS=1 uv run pytest ../../tests/ica_metering_exporter -v + +test-all: test test-integration + +check-all: + uv run ruff format --check . + uv run ruff check . + uv run mypy cpex_ica_metering_exporter tests + +build: + uv build --project . --out-dir ./dist + +install: + uv pip install -e . + +ci-build: check-all test build + +ci: ci-build test-integration + +clean: + rm -rf .mypy_cache .pytest_cache .ruff_cache build dist diff --git a/plugins/python/ica_metering_exporter/README.md b/plugins/python/ica_metering_exporter/README.md new file mode 100644 index 0000000..cf7e87c --- /dev/null +++ b/plugins/python/ica_metering_exporter/README.md @@ -0,0 +1,88 @@ +# ICA Metering Exporter + +Pure-Python CPEX plugin that exports MCP tool pre/post invocation metering to the ICA core-services endpoint. Ported from [IBM/mcp-context-forge PR #5696](https://github.com/IBM/mcp-context-forge/pull/5696). + +## Features + +- Records tool latency, result status, token counts, gateway identity, and transport. +- Resolves model attribution through a deterministic seven-level cascade. +- Attributes app, MCP client user agent, assistant, agent, and digital-IBMer context from inbound HTTP extensions. +- Authenticates with an HS256 service JWT, falling back to a static metering token. +- Awaits each export sequentially and treats export failures as best effort so tool execution continues. +- Is disabled by default. + +## Configuration + +| Key | Meaning | +|---|---| +| `enabled` | Enables client creation and export. Defaults to `false`. | +| `metering_url` | ICA metering endpoint URL. | +| `metering_token` | Static fallback token sent as `X-MCP-Metering-Token`. | +| `jwt_secret` | HS256 secret used to issue a one-day service JWT. Takes precedence over the static token. | +| `gateways[].id` | Gateway identifier for a per-gateway model fallback. | +| `gateways[].default_model` | Model fallback for the matching gateway identifier. | +| `global_default_model` | Last configured model fallback. | +| `include_model_source` | Adds the selected model-source label to the ICA payload. | + +```yaml +plugins: + - name: ica_metering_exporter + kind: cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 200 + capabilities: [read_headers] + config: + enabled: false + metering_url: "https://metering.example.invalid/events" +``` + +Supply tokens and JWT secrets through deployment environment/configuration secret injection; never commit them. + +## Inbound headers and capability + +Caller attribution reads only `extensions.http.headers`, using case-insensitive names. It never reads payload headers and never invents app or persona values when attribution headers are absent. Unit tests pass `Extensions` directly and therefore intentionally bypass gateway capability filtering. + +Gateway registration **must grant `read_headers`** to this plugin. CPEX guards `HttpExtension`; without the capability the gateway strips inbound headers and attribution remains empty. + +Recognized identity headers include `X-OpenWebUI-Model-Id`, `X-App-Id`, `X-MCP-Client-Name`, `X-MCP-Client-Version`, `X-Forwarded-User-Agent`, `User-Agent`, and the nine persona headers used by ICA/Open WebUI. + +## Model precedence + +The first available source wins: + +1. `X-OpenWebUI-Model-Id` captured during pre-invoke +2. session `global_context.metadata.model_name` +3. `MCP_DEFAULT_MODEL` +4. tool-call `meta_data.model` +5. configured gateway `default_model` +6. configured `global_default_model` +7. unknown (`None`) + +## OpenTelemetry metadata + +When `extensions.request.trace_id` is non-empty, post-invoke returns: + +```python +result.metadata["ica_metering_exporter"] = { + "export_status": "sent", + "latency_ms": 12, + "model_source": "transport_header", + "stage": "tool_post_invoke", +} +``` + +The trace ID is an input gate only and is never emitted. Metadata contains aggregated operational fields only—never tokens, headers, payloads, app IDs, user agents, persona data, arguments, or output. + +## Registration mode + +The export is awaited and best effort. Register the plugin in the framework's default `SEQUENTIAL` mode. `FIRE_AND_FORGET` discards hook return values and would therefore discard the returned OpenTelemetry metadata. + +## Development + +```bash +make sync +make check-all +make test +make build +``` diff --git a/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/__init__.py b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/__init__.py new file mode 100644 index 0000000..8e68a31 --- /dev/null +++ b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ICA metering exporter plugin package.""" + +from cpex_ica_metering_exporter.plugin import IcaMeteringExporterPlugin + +__all__ = ["IcaMeteringExporterPlugin"] diff --git a/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/metering.py b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/metering.py new file mode 100644 index 0000000..2a8afd1 --- /dev/null +++ b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/metering.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Metering value extraction helpers.""" + +from __future__ import annotations + +from typing import Any + + +def get_header(headers: dict[str, Any], name: str) -> str | None: + """Return a header value using case-insensitive lookup.""" + lowered_name = name.lower() + for key, value in headers.items(): + if isinstance(key, str) and key.lower() == lowered_name and value is not None: + return str(value) + return None + + +def coerce_int(value: Any) -> int | None: + """Coerce a value to an integer when possible.""" + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def is_error(result: Any) -> bool: + """Return whether a tool result declares an error.""" + return isinstance(result, dict) and bool(result.get("isError", False)) + + +def extract_error_message(result: Any) -> str | None: + """Extract a declared tool error message.""" + if not isinstance(result, dict) or not result.get("isError"): + return None + content = result.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict): + text = block.get("text") + if isinstance(text, str): + return text + value = result.get("errorMessage") + return str(value) if value is not None else None + + +def extract_tokens(result: Any) -> dict[str, Any]: + """Extract token metadata from a tool result.""" + if not isinstance(result, dict): + return {} + for metadata_key in ("_meta", "meta"): + metadata = result.get(metadata_key) + if isinstance(metadata, dict): + tokens = metadata.get("tokens") + if isinstance(tokens, dict): + return tokens + return {} diff --git a/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/plugin-manifest.yaml b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/plugin-manifest.yaml new file mode 100644 index 0000000..49a110e --- /dev/null +++ b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/plugin-manifest.yaml @@ -0,0 +1,11 @@ +description: "Export MCP tool invocation metrics to ICA metering service" +author: "ICA Team" +version: "0.1.0" +kind: "cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin" +available_hooks: + - "tool_pre_invoke" + - "tool_post_invoke" +default_configs: + metering_url: "" + metering_token: "" + enabled: false diff --git a/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/plugin.py b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/plugin.py new file mode 100644 index 0000000..646c3f2 --- /dev/null +++ b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/plugin.py @@ -0,0 +1,264 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ICA metering exporter plugin.""" + +from __future__ import annotations + +import logging +import os +import time +from typing import Any, ClassVar, Optional + +import httpx +from cpex.framework import ( + Plugin, + PluginConfig, + PluginContext, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) +from cpex.framework.constants import GATEWAY_METADATA + +from cpex_ica_metering_exporter.metering import ( + coerce_int, + extract_error_message, + extract_tokens, + get_header, + is_error, +) +from cpex_ica_metering_exporter.transport import ExportRequest, get_service_jwt, send_to_ica + +logger = logging.getLogger(__name__) + + +class IcaMeteringExporterPlugin(Plugin): + """Export MCP tool invocation metrics to ICA metering service.""" + + CALL_CONTEXT_HEADERS: ClassVar[dict[str, str]] = { + "ica_llm_call_type": "llm_call_type", + "ica_assistant_name": "assistant_name", + "ica_assistant_uuid": "assistant_uuid", + "ica_agent_name": "agent_name", + "ica_agent_uuid": "agent_uuid", + "ica_agent_tool_ids": "agent_tool_ids", + "ica_digital_ibmer_name": "digital-ibmer_name", + "ica_digital_ibmer_uuid": "digital-ibmer_uuid", + "ica_digital_ibmer_tool_ids": "digital-ibmer_tool_ids", + } + + _get_header = staticmethod(get_header) + _coerce_int = staticmethod(coerce_int) + _get_service_jwt = staticmethod(get_service_jwt) + _is_error = staticmethod(is_error) + _extract_error_message = staticmethod(extract_error_message) + _extract_tokens = staticmethod(extract_tokens) + + def __init__(self, config: PluginConfig) -> None: + """Initialize normalized configuration and the optional HTTP client.""" + super().__init__(config) + self.telemetry_config: dict[str, Any] = dict(config.config or {}) + self.http_client: Optional[httpx.AsyncClient] = None + self.env_model_name: Optional[str] = None + raw_secret = self.telemetry_config.get("jwt_secret") + self._jwt_secret: Optional[str] = raw_secret if isinstance(raw_secret, str) and raw_secret else None + self._gateway_configs: dict[str, dict[str, Any]] = {} + raw_gateways = self.telemetry_config.get("gateways", []) + if isinstance(raw_gateways, list): + for gateway in raw_gateways: + if not isinstance(gateway, dict): + continue + gateway_id = gateway.get("id") + if isinstance(gateway_id, str) and gateway_id: + self._gateway_configs[gateway_id] = gateway + if bool(self.telemetry_config.get("enabled", False)): + self.http_client = httpx.AsyncClient( + timeout=httpx.Timeout(5.0, connect=2.0), + limits=httpx.Limits(max_keepalive_connections=5), + ) + self.env_model_name = os.getenv("MCP_DEFAULT_MODEL") + + async def shutdown(self) -> None: + """Close the HTTP client held by the plugin.""" + if self.http_client is not None: + await self.http_client.aclose() + self.http_client = None + + async def tool_pre_invoke( + self, + payload: ToolPreInvokePayload, + context: PluginContext, + extensions: Any = None, + ) -> ToolPreInvokeResult: + """Record start time and caller attribution from HTTP extensions.""" + if not bool(self.telemetry_config.get("enabled", False)): + return ToolPreInvokeResult(continue_processing=True) + context.state["ica_metering_start_time"] = time.monotonic() + raw_headers: dict[str, Any] = {} + if extensions is not None: + http_extension = getattr(extensions, "http", None) + if http_extension is not None: + extension_headers = getattr(http_extension, "headers", None) + if isinstance(extension_headers, dict): + raw_headers = extension_headers + model_name = self._get_header(raw_headers, "X-OpenWebUI-Model-Id") + if model_name: + context.state["ica_metering_model_name"] = model_name + app_id = self._get_header(raw_headers, "X-App-Id") + if app_id: + context.state["ica_app_id"] = app_id + client_name = self._get_header(raw_headers, "X-MCP-Client-Name") + client_version = self._get_header(raw_headers, "X-MCP-Client-Version") + if client_name: + context.state["ica_mcp_client_name"] = client_name + context.state["ica_mcp_client_version"] = client_version + user_agent = self._get_header(raw_headers, "X-Forwarded-User-Agent") + if not user_agent and client_name: + user_agent = f"{client_name}/{client_version}" if client_version else client_name + if not user_agent: + user_agent = self._get_header(raw_headers, "User-Agent") + if user_agent: + context.state["ica_user_agent"] = user_agent + if not app_id and client_name: + context.state["ica_app_id"] = f"api:{client_name}" + elif not app_id and user_agent: + user_agent_name = user_agent.split("/", maxsplit=1)[0].strip() + if "/" not in user_agent: + user_agent_name = user_agent.split(maxsplit=1)[0].strip() + if user_agent_name and not user_agent_name.startswith("Mozilla"): + context.state["ica_app_id"] = f"api:{user_agent_name}" + for state_key, header_name in self.CALL_CONTEXT_HEADERS.items(): + value = self._get_header(raw_headers, header_name) + if value: + context.state[state_key] = value + logger.debug("ICA metering: Pre-invoke for tool %s", payload.name) + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke( + self, + payload: ToolPostInvokePayload, + context: PluginContext, + extensions: Any = None, + ) -> ToolPostInvokeResult: + """Build and export one tool invocation metering record.""" + if not bool(self.telemetry_config.get("enabled", False)): + return ToolPostInvokeResult(continue_processing=True) + started_at = context.state.get("ica_metering_start_time") + latency_ms: Optional[int] = None + if isinstance(started_at, (int, float)): + latency_ms = max(0, int((time.monotonic() - started_at) * 1000)) + if not payload.name: + logger.warning("ICA metering: Tool name is empty, skipping") + return ToolPostInvokeResult(continue_processing=True) + gateway_raw = context.global_context.metadata.get(GATEWAY_METADATA, {}) + if hasattr(gateway_raw, "model_dump"): + dumped_gateway = gateway_raw.model_dump() + gateway_meta = dumped_gateway if isinstance(dumped_gateway, dict) else {} + elif isinstance(gateway_raw, dict): + gateway_meta = gateway_raw + else: + gateway_meta = {} + context_raw = context.global_context.metadata.get("meta_data", {}) + context_meta = context_raw if isinstance(context_raw, dict) else {} + model_name, model_source = self._resolve_model_name(context, context_meta, gateway_meta) + raw_transport = gateway_meta.get("transport", "") + transport = raw_transport.lower() if isinstance(raw_transport, str) else "" + if transport in ("streamablehttp", "streamable_http"): + request_type = "STREAMABLE_HTTP" + elif transport == "sse": + request_type = "SSE" + else: + request_type = transport.upper() if transport else "UNKNOWN" + tokens = self._extract_tokens(payload.result) + tool_details: dict[str, Any] = { + "toolName": payload.name, + "serverId": context.global_context.server_id or "unknown", + "serverName": gateway_meta.get("name"), + "gatewayId": gateway_meta.get("id"), + "integrationType": "MCP", + "requestType": request_type, + "latencyMs": latency_ms, + "hasError": self._is_error(payload.result), + "errorMessage": self._extract_error_message(payload.result), + "cached": context.state.get("cache_hit", False), + "retryAttempt": context.state.get("retry_count", 0), + "modelName": model_name, + "traceId": context.global_context.request_id, + "tokenInput": self._coerce_int(tokens.get("input")), + "tokenOutput": self._coerce_int(tokens.get("output")), + "source": "ContextForge", + } + global_user = context.global_context.user + user_email = context.user_email or (global_user if isinstance(global_user, str) else None) or "unknown" + metering_payload: dict[str, Any] = { + "userEmail": user_email, + "teamName": context.global_context.tenant_id or "unknown", + "appId": context.state.get("ica_app_id"), + "userAgent": context.state.get("ica_user_agent"), + "llmCallType": context.state.get("ica_llm_call_type"), + "assistantName": context.state.get("ica_assistant_name"), + "assistantUuid": context.state.get("ica_assistant_uuid"), + "agentName": context.state.get("ica_agent_name"), + "agentUuid": context.state.get("ica_agent_uuid"), + "agentToolIds": context.state.get("ica_agent_tool_ids"), + "digitalIbmerName": context.state.get("ica_digital_ibmer_name"), + "digitalIbmerUuid": context.state.get("ica_digital_ibmer_uuid"), + "digitalIbmerToolIds": context.state.get("ica_digital_ibmer_tool_ids"), + "toolDetails": tool_details, + } + if bool(self.telemetry_config.get("include_model_source", False)): + metering_payload["_metadata"] = {"modelSource": model_source} + export_status = await self._send_to_ica(metering_payload) + trace_id = getattr(getattr(extensions, "request", None), "trace_id", None) if extensions is not None else None + metadata = ( + { + "ica_metering_exporter": { + "export_status": export_status, + "latency_ms": latency_ms, + "model_source": model_source, + "stage": "tool_post_invoke", + } + } + if trace_id + else {} + ) + return ToolPostInvokeResult(continue_processing=True, metadata=metadata) + + def _resolve_model_name( + self, + context: PluginContext, + context_meta: dict[str, Any], + gateway_meta: dict[str, Any], + ) -> tuple[Optional[str], Optional[str]]: + """Resolve the model name through the seven-level precedence cascade.""" + model = context.state.get("ica_metering_model_name") + if model: + return str(model), "transport_header" + model = context.global_context.metadata.get("model_name") + if model: + return str(model), "session_init" + if self.env_model_name: + return self.env_model_name, "environment" + model = context_meta.get("model") + if model: + return str(model), "tool_metadata" + gateway_id = gateway_meta.get("id") + if isinstance(gateway_id, str) and gateway_id in self._gateway_configs: + model = self._gateway_configs[gateway_id].get("default_model") + if model: + return str(model), "gateway_default" + model = self.telemetry_config.get("global_default_model") + if model: + return str(model), "global_default" + return None, "unknown" + + async def _send_to_ica(self, payload: dict[str, Any]) -> str: + """Delegate an awaited best-effort export to the transport boundary.""" + return await send_to_ica( + ExportRequest( + client=self.http_client, + config=self.telemetry_config, + jwt_secret=self._jwt_secret, + payload=payload, + ) + ) diff --git a/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/transport.py b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/transport.py new file mode 100644 index 0000000..449b538 --- /dev/null +++ b/plugins/python/ica_metering_exporter/cpex_ica_metering_exporter/transport.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ICA metering transport and authentication.""" + +from __future__ import annotations + +import logging +import os +import time +from dataclasses import dataclass +from typing import Any + +import httpx +import jwt + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class ExportRequest: + """Inputs for one ICA metering export.""" + + client: httpx.AsyncClient | None + config: dict[str, Any] + jwt_secret: str | None + payload: dict[str, Any] + + +def get_service_jwt(secret: str) -> str: + """Create an HS256 service token for ICA metering.""" + now = int(time.time()) + claims = { + "sub": "contextforge-metering", + "service": "mcp-context-forge", + "instance": os.getenv("HOSTNAME", "unknown"), + "scope": "metering:write", + "iat": now, + "exp": now + 86_400, + } + return jwt.encode(claims, secret, algorithm="HS256") + + +async def send_to_ica(request: ExportRequest) -> str: + """Await one best-effort ICA export and return its operational status.""" + if request.client is None: + return "failed" + metering_url = request.config.get("metering_url") + if not isinstance(metering_url, str) or not metering_url: + logger.warning("ICA metering URL not configured") + return "skipped_no_url" + metering_token = request.config.get("metering_token") + if request.jwt_secret: + headers = {"Authorization": f"Bearer {get_service_jwt(request.jwt_secret)}"} + elif isinstance(metering_token, str) and metering_token: + headers = {"X-MCP-Metering-Token": metering_token} + else: + logger.warning("ICA metering: neither jwt_secret nor metering_token configured") + return "skipped_no_auth" + try: + response = await request.client.post(metering_url, json=request.payload, headers=headers) + if response.status_code != httpx.codes.ACCEPTED: + logger.warning("ICA metering endpoint returned %s", response.status_code) + return "failed" + except httpx.TimeoutException: + logger.warning("ICA metering: Timeout sending metrics") + except httpx.NetworkError: + logger.warning("ICA metering: Network error") + except httpx.HTTPStatusError as error: + logger.exception("ICA metering: HTTP %s", error.response.status_code) + except Exception: + logger.exception("ICA metering: Failed to send metrics") + else: + logger.debug("ICA metering: Successfully sent metrics") + return "sent" + return "failed" diff --git a/plugins/python/ica_metering_exporter/pyproject.toml b/plugins/python/ica_metering_exporter/pyproject.toml new file mode 100644 index 0000000..b0415f8 --- /dev/null +++ b/plugins/python/ica_metering_exporter/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cpex-ica-metering-exporter" +version = "0.1.0" +description = "Export MCP tool invocation metrics to ICA core-services metering endpoint" +authors = [{ name = "ICA Team" }] +license = { text = "Apache-2.0" } +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["cpex>=0.1.3,<0.2", "httpx>=0.27,<1", "PyJWT>=2.8,<3"] +classifiers = [ + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.entry-points."cpex.plugins"] +ica_metering_exporter = "cpex_ica_metering_exporter.plugin:IcaMeteringExporterPlugin" + +[tool.hatch.build.targets.wheel] +packages = ["cpex_ica_metering_exporter"] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "pytest-asyncio>=1.3.0", + "ruff", + "mypy", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = ["-ra", "--strict-config", "--strict-markers"] +filterwarnings = ["error"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unreachable = true +follow_untyped_imports = true + +[[tool.mypy.overrides]] +module = ["cpex", "cpex.*"] +ignore_missing_imports = true + +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = ["ALL"] +ignore = ["ANN401", "COM812", "CPY001", "ISC001", "D203", "D213", "UP045"] + +[tool.ruff.lint.per-file-ignores] +"cpex_ica_metering_exporter/plugin.py" = ["C901", "PLR0911", "PLR0912"] +"cpex_ica_metering_exporter/transport.py" = ["C901"] +"tests/**/*.py" = ["S101", "ARG", "PLR2004", "SLF001", "D", "INP001", "PT018"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true diff --git a/plugins/python/ica_metering_exporter/tests/test_build_contract.py b/plugins/python/ica_metering_exporter/tests/test_build_contract.py new file mode 100644 index 0000000..666e547 --- /dev/null +++ b/plugins/python/ica_metering_exporter/tests/test_build_contract.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Build contract tests for plugin-local distribution artifacts.""" + +from pathlib import Path + + +def test_build_target_writes_plugin_local_artifacts() -> None: + # Given the plugin Makefile executed by the CI target. + makefile_path = Path(__file__).parents[1] / "Makefile" + + # When its build recipe is inspected. + makefile = makefile_path.read_text() + + # Then uv targets the plugin-local distribution directory. + assert "uv build --project . --out-dir ./dist" in makefile diff --git a/plugins/python/ica_metering_exporter/tests/test_ica_metering_exporter.py b/plugins/python/ica_metering_exporter/tests/test_ica_metering_exporter.py new file mode 100644 index 0000000..e637566 --- /dev/null +++ b/plugins/python/ica_metering_exporter/tests/test_ica_metering_exporter.py @@ -0,0 +1,799 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests ported from IBM/mcp-context-forge PR #5696.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock + +import httpx +import jwt +import pytest +from cpex.framework import ( + GlobalContext, + PluginConfig, + PluginContext, + ToolPostInvokePayload, + ToolPreInvokePayload, +) +from cpex.framework.constants import GATEWAY_METADATA +from cpex.framework.extensions import Extensions, RequestExtension +from cpex.framework.extensions.http import HttpExtension +from pydantic import BaseModel + +from cpex_ica_metering_exporter import IcaMeteringExporterPlugin + +Config = dict[str, Any] +Payload = dict[str, Any] +JWT_FIXTURE = "fixture-jwt-secret-at-least-thirty-two-bytes" + + +def _plugin( + monkeypatch: pytest.MonkeyPatch, + config: Config | None = None, + *, + mock_send: bool = True, +) -> IcaMeteringExporterPlugin: + resolved = ( + config + if config is not None + else { + "enabled": True, + "metering_url": "https://metering.example.invalid/event", + "metering_token": "fixture-static-token", + } + ) + plugin = IcaMeteringExporterPlugin( + PluginConfig( + name="ica_metering_test", + kind="cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin", + hooks=["tool_pre_invoke", "tool_post_invoke"], + config=resolved, + ) + ) + if mock_send: + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent"), raising=False) + return plugin + + +def _context(metadata: Config | None = None, *, user: str | Config = "user@example.test") -> PluginContext: + return PluginContext( + global_context=GlobalContext( + request_id="request-123", + user=user, + tenant_id="team-1", + server_id="server-1", + metadata=metadata or {}, + ) + ) + + +def _extensions(headers: dict[str, str] | None = None, trace_id: str | None = "trace-123") -> Extensions: + return Extensions( + http=HttpExtension(headers=headers or {}), + request=RequestExtension(trace_id=trace_id), + ) + + +def _pre(name: str = "tool") -> ToolPreInvokePayload: + return ToolPreInvokePayload(name=name, args={}) + + +def _post(name: str = "tool", result: Any = None) -> ToolPostInvokePayload: + return ToolPostInvokePayload(name=name, result=result if result is not None else {"isError": False}) + + +def _sent(plugin: IcaMeteringExporterPlugin) -> Payload: + sender = plugin._send_to_ica + assert isinstance(sender, AsyncMock) + await_args = sender.await_args + assert await_args is not None + value = await_args.args[0] + assert isinstance(value, dict) + return value + + +@pytest.mark.asyncio +async def test_pre_invoke_records_timestamp(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + context = _context() + await plugin.tool_pre_invoke(_pre(), context) + assert isinstance(context.state["ica_metering_start_time"], float) + + +@pytest.mark.asyncio +async def test_pre_invoke_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": False}) + context = _context() + result = await plugin.tool_pre_invoke(_pre(), context) + assert result.continue_processing is True and context.state == {} + + +@pytest.mark.asyncio +async def test_pre_invoke_accepts_extensions_none(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + result = await plugin.tool_pre_invoke(_pre(), _context(), None) + assert result.continue_processing is True + + +@pytest.mark.asyncio +async def test_pre_invoke_extracts_app_id(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions({"x-app-id": "app-1"})) + assert context.state["ica_app_id"] == "app-1" + + +@pytest.mark.asyncio +async def test_pre_invoke_extracts_model_case_insensitively(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions({"x-OPENwebui-MODEL-id": "model-1"})) + assert context.state["ica_metering_model_name"] == "model-1" + + +@pytest.mark.asyncio +async def test_pre_invoke_extracts_mcp_client(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + headers = {"X-MCP-Client-Name": "opencode", "x-mcp-client-version": "1.5.0"} + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions(headers)) + assert context.state["ica_mcp_client_name"] == "opencode" + assert context.state["ica_mcp_client_version"] == "1.5.0" + + +@pytest.mark.asyncio +async def test_pre_invoke_uses_forwarded_user_agent(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + headers = {"User-Agent": "fallback/1", "X-Forwarded-User-Agent": "forwarded/2"} + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions(headers)) + assert context.state["ica_user_agent"] == "forwarded/2" + + +@pytest.mark.asyncio +async def test_pre_invoke_uses_client_user_agent_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + headers = {"X-MCP-Client-Name": "opencode", "X-MCP-Client-Version": "2.0"} + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions(headers)) + assert context.state["ica_user_agent"] == "opencode/2.0" + + +@pytest.mark.asyncio +async def test_pre_invoke_uses_bare_client_name(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions({"X-MCP-Client-Name": "client"})) + assert context.state["ica_user_agent"] == "client" + + +@pytest.mark.asyncio +async def test_pre_invoke_uses_user_agent_last(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions({"User-Agent": "sdk/3"})) + assert context.state["ica_user_agent"] == "sdk/3" + + +@pytest.mark.asyncio +async def test_pre_invoke_derives_app_from_client(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions({"X-MCP-Client-Name": "client"})) + assert context.state["ica_app_id"] == "api:client" + + +@pytest.mark.asyncio +async def test_pre_invoke_derives_app_from_user_agent(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions({"User-Agent": "sdk/3.0"})) + assert context.state["ica_app_id"] == "api:sdk" + + +@pytest.mark.asyncio +async def test_pre_invoke_does_not_derive_browser_app(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions({"User-Agent": "Mozilla/5.0"})) + assert "ica_app_id" not in context.state + + +@pytest.mark.asyncio +async def test_pre_invoke_preserves_explicit_app(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + headers = {"X-App-Id": "explicit", "X-MCP-Client-Name": "client"} + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions(headers)) + assert context.state["ica_app_id"] == "explicit" + + +@pytest.mark.asyncio +async def test_pre_invoke_extracts_all_persona_headers(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + headers = { + "LlM_CaLl_TyPe": "assistant", + "ASSISTANT_NAME": "Helper", + "assistant_uuid": "a-1", + "agent_name": "Agent", + "agent_uuid": "g-1", + "agent_tool_ids": "t-1", + "digital-ibmer_name": "Digital", + "digital-ibmer_uuid": "d-1", + "digital-ibmer_tool_ids": "t-2", + } + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions(headers)) + assert {key for key in context.state if key.startswith("ica_") and key != "ica_metering_start_time"} >= { + "ica_llm_call_type", + "ica_assistant_name", + "ica_assistant_uuid", + "ica_agent_name", + "ica_agent_uuid", + "ica_agent_tool_ids", + "ica_digital_ibmer_name", + "ica_digital_ibmer_uuid", + "ica_digital_ibmer_tool_ids", + } + + +@pytest.mark.asyncio +async def test_pre_invoke_absent_headers_do_not_fabricate_attribution(monkeypatch: pytest.MonkeyPatch) -> None: + context = _context() + await _plugin(monkeypatch).tool_pre_invoke(_pre(), context, _extensions()) + assert set(context.state) == {"ica_metering_start_time"} + + +@pytest.mark.asyncio +async def test_model_priority_transport_header(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "global_default_model": "global"}) + plugin.env_model_name = "environment" + plugin._gateway_configs = {"gateway": {"default_model": "gateway-default"}} + context = _context({"model_name": "session", "meta_data": {"model": "tool"}, GATEWAY_METADATA: {"id": "gateway"}}) + await plugin.tool_pre_invoke(_pre(), context, _extensions({"X-OpenWebUI-Model-Id": "transport"})) + await plugin.tool_post_invoke(_post(), context) + assert _sent(plugin)["toolDetails"]["modelName"] == "transport" + + +@pytest.mark.asyncio +async def test_model_priority_session(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "global_default_model": "global"}) + plugin.env_model_name = "environment" + context = _context({"model_name": "session", "meta_data": {"model": "tool"}}) + await plugin.tool_post_invoke(_post(), context) + assert _sent(plugin)["toolDetails"]["modelName"] == "session" + + +@pytest.mark.asyncio +async def test_model_priority_environment(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "global_default_model": "global"}) + plugin.env_model_name = "environment" + context = _context({"meta_data": {"model": "tool"}}) + await plugin.tool_post_invoke(_post(), context) + assert _sent(plugin)["toolDetails"]["modelName"] == "environment" + + +@pytest.mark.asyncio +async def test_model_priority_tool_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "global_default_model": "global"}) + context = _context({"meta_data": {"model": "tool"}}) + await plugin.tool_post_invoke(_post(), context) + assert _sent(plugin)["toolDetails"]["modelName"] == "tool" + + +@pytest.mark.asyncio +async def test_model_priority_gateway_default(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin( + monkeypatch, {"enabled": True, "gateways": [{"id": "gateway", "default_model": "gateway-default"}]} + ) + context = _context({GATEWAY_METADATA: {"id": "gateway"}}) + await plugin.tool_post_invoke(_post(), context) + assert _sent(plugin)["toolDetails"]["modelName"] == "gateway-default" + + +class _GatewayMetadata(BaseModel): + id: str + name: str = "Gateway" + transport: str = "sse" + + +@pytest.mark.asyncio +async def test_model_gateway_default_accepts_model_dump(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "gateways": [{"id": "gateway", "default_model": "gateway-model"}]}) + context = _context({GATEWAY_METADATA: _GatewayMetadata(id="gateway")}) + await plugin.tool_post_invoke(_post(), context) + assert _sent(plugin)["toolDetails"]["modelName"] == "gateway-model" + + +@pytest.mark.asyncio +async def test_model_priority_global_default(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "global_default_model": "global"}) + await plugin.tool_post_invoke(_post(), _context()) + assert _sent(plugin)["toolDetails"]["modelName"] == "global" + + +@pytest.mark.asyncio +async def test_model_priority_unknown(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context()) + assert _sent(plugin)["toolDetails"]["modelName"] is None + + +@pytest.mark.asyncio +async def test_model_source_is_included_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "include_model_source": True}) + await plugin.tool_post_invoke(_post(), _context({"model_name": "session"})) + assert _sent(plugin)["_metadata"]["modelSource"] == "session_init" + + +@pytest.mark.asyncio +async def test_model_source_is_omitted_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context({"model_name": "session"})) + assert "_metadata" not in _sent(plugin) + + +@pytest.mark.asyncio +async def test_post_invoke_calculates_latency(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + context = _context() + await plugin.tool_pre_invoke(_pre(), context) + await plugin.tool_post_invoke(_post(), context) + assert _sent(plugin)["toolDetails"]["latencyMs"] >= 0 + + +@pytest.mark.asyncio +async def test_post_invoke_latency_is_none_without_pre(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context()) + assert _sent(plugin)["toolDetails"]["latencyMs"] is None + + +@pytest.mark.asyncio +async def test_post_invoke_skips_empty_name(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + result = await plugin.tool_post_invoke(_post(""), _context()) + sender = plugin._send_to_ica + assert result.continue_processing is True and isinstance(sender, AsyncMock) and sender.await_count == 0 + + +@pytest.mark.asyncio +async def test_post_invoke_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": False}) + result = await plugin.tool_post_invoke(_post(), _context(), _extensions()) + assert result.continue_processing is True and result.metadata in ({}, None) + + +@pytest.mark.asyncio +async def test_post_invoke_structured_payload(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + context = _context({GATEWAY_METADATA: {"id": "gw-1", "name": "Gateway", "transport": "streamablehttp"}}) + await plugin.tool_post_invoke(_post("weather", {"meta": {"tokens": {"input": 10, "output": 20}}}), context) + sent = _sent(plugin) + assert sent["userEmail"] == "user@example.test" + assert sent["teamName"] == "team-1" + assert sent["toolDetails"] == { + "toolName": "weather", + "serverId": "server-1", + "serverName": "Gateway", + "gatewayId": "gw-1", + "integrationType": "MCP", + "requestType": "STREAMABLE_HTTP", + "latencyMs": None, + "hasError": False, + "errorMessage": None, + "cached": False, + "retryAttempt": 0, + "modelName": None, + "traceId": "request-123", + "tokenInput": 10, + "tokenOutput": 20, + "source": "ContextForge", + } + + +@pytest.mark.asyncio +async def test_post_invoke_includes_attribution(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + context = _context() + headers = {"X-App-Id": "app", "User-Agent": "client/1", "assistant_name": "Helper"} + await plugin.tool_pre_invoke(_pre(), context, _extensions(headers)) + await plugin.tool_post_invoke(_post(), context) + sent = _sent(plugin) + assert (sent["appId"], sent["userAgent"], sent["assistantName"]) == ("app", "client/1", "Helper") + + +@pytest.mark.asyncio +async def test_post_invoke_does_not_fabricate_attribution(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context()) + sent = _sent(plugin) + assert sent["appId"] is None and sent["userAgent"] is None and sent["assistantName"] is None + + +@pytest.mark.asyncio +async def test_post_invoke_user_email_from_string(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context(user="person@example.test")) + assert _sent(plugin)["userEmail"] == "person@example.test" + + +@pytest.mark.asyncio +async def test_post_invoke_user_email_from_dict_property(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context(user={"email": "dict@example.test"})) + assert _sent(plugin)["userEmail"] == "dict@example.test" + + +@pytest.mark.asyncio +async def test_request_type_streamablehttp(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context({GATEWAY_METADATA: {"transport": "streamablehttp"}})) + assert _sent(plugin)["toolDetails"]["requestType"] == "STREAMABLE_HTTP" + + +@pytest.mark.asyncio +async def test_request_type_streamable_http(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context({GATEWAY_METADATA: {"transport": "streamable_http"}})) + assert _sent(plugin)["toolDetails"]["requestType"] == "STREAMABLE_HTTP" + + +@pytest.mark.asyncio +async def test_request_type_sse(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context({GATEWAY_METADATA: {"transport": "sse"}})) + assert _sent(plugin)["toolDetails"]["requestType"] == "SSE" + + +@pytest.mark.asyncio +async def test_request_type_unknown(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(), _context()) + assert _sent(plugin)["toolDetails"]["requestType"] == "UNKNOWN" + + +@pytest.mark.asyncio +async def test_error_detection_true(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(result={"isError": True, "errorMessage": "failed"}), _context()) + details = _sent(plugin)["toolDetails"] + assert details["hasError"] is True and details["errorMessage"] == "failed" + + +@pytest.mark.asyncio +async def test_gateway_tool_result_exports_content_error(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + gateway_result = { + "content": [{"type": "text", "text": "Tool invocation failed: upstream unavailable"}], + "isError": True, + "structuredContent": None, + "_meta": {"tokens": {"input": 2, "output": 3}}, + } + + await plugin.tool_post_invoke(_post(result=gateway_result), _context()) + + details = _sent(plugin)["toolDetails"] + assert details["hasError"] is True + assert details["errorMessage"] == "Tool invocation failed: upstream unavailable" + assert (details["tokenInput"], details["tokenOutput"]) == (2, 3) + + +@pytest.mark.asyncio +async def test_error_detection_false(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(result={"isError": False}), _context()) + assert _sent(plugin)["toolDetails"]["hasError"] is False + + +@pytest.mark.asyncio +async def test_error_detection_non_dict(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(result="plain"), _context()) + assert _sent(plugin)["toolDetails"]["hasError"] is False + + +@pytest.mark.asyncio +async def test_token_extraction_integer(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(result={"meta": {"tokens": {"input": 10, "output": 20}}}), _context()) + details = _sent(plugin)["toolDetails"] + assert (details["tokenInput"], details["tokenOutput"]) == (10, 20) + + +@pytest.mark.asyncio +async def test_gateway_tool_result_exports_meta_tokens(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + gateway_result = { + "content": [{"type": "text", "text": "completed"}], + "isError": False, + "structuredContent": None, + "_meta": {"tokens": {"input": 13, "output": 21}}, + } + + await plugin.tool_post_invoke(_post(result=gateway_result), _context()) + + details = _sent(plugin)["toolDetails"] + assert (details["tokenInput"], details["tokenOutput"]) == (13, 21) + assert details["hasError"] is False + assert details["errorMessage"] is None + + +@pytest.mark.asyncio +async def test_token_extraction_coerces_values(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(result={"meta": {"tokens": {"input": 10.9, "output": "20"}}}), _context()) + details = _sent(plugin)["toolDetails"] + assert (details["tokenInput"], details["tokenOutput"]) == (10, 20) + + +@pytest.mark.asyncio +async def test_token_extraction_malformed_meta(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + await plugin.tool_post_invoke(_post(result={"meta": "invalid"}), _context()) + details = _sent(plugin)["toolDetails"] + assert details["tokenInput"] is None and details["tokenOutput"] is None + + +@pytest.mark.asyncio +async def test_cache_and_retry_state(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + context = _context() + context.state.update({"cache_hit": True, "retry_count": 3}) + await plugin.tool_post_invoke(_post(), context) + details = _sent(plugin)["toolDetails"] + assert details["cached"] is True and details["retryAttempt"] == 3 + + +@pytest.mark.asyncio +async def test_http_send_static_token_with_mock_transport(monkeypatch: pytest.MonkeyPatch) -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(202) + + plugin = _plugin(monkeypatch, mock_send=False) + assert plugin.http_client is not None + await plugin.http_client.aclose() + plugin.http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + status = await plugin._send_to_ica({"key": "value"}) + await plugin.shutdown() + assert status == "sent" and captured[0].headers["x-mcp-metering-token"] == "fixture-static-token" + + +@pytest.mark.asyncio +async def test_http_send_jwt_with_mock_transport(monkeypatch: pytest.MonkeyPatch) -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(202) + + plugin = _plugin( + monkeypatch, + {"enabled": True, "metering_url": "https://example.invalid", "jwt_secret": JWT_FIXTURE}, + mock_send=False, + ) + assert plugin.http_client is not None + await plugin.http_client.aclose() + plugin.http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + status = await plugin._send_to_ica({"key": "value"}) + await plugin.shutdown() + assert status == "sent" and captured[0].headers["authorization"].startswith("Bearer ") + + +@pytest.mark.asyncio +async def test_http_send_is_awaited_sequentially(monkeypatch: pytest.MonkeyPatch) -> None: + completed = False + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal completed + completed = True + return httpx.Response(202) + + plugin = _plugin(monkeypatch, mock_send=False) + assert plugin.http_client is not None + await plugin.http_client.aclose() + plugin.http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + await plugin.tool_post_invoke(_post(), _context()) + await plugin.shutdown() + assert completed is True + + +@pytest.mark.asyncio +async def test_http_non_202_returns_failed(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, mock_send=False) + assert plugin.http_client is not None + await plugin.http_client.aclose() + plugin.http_client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(500))) + status = await plugin._send_to_ica({}) + await plugin.shutdown() + assert status == "failed" + + +@pytest.mark.asyncio +async def test_http_network_error_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + message = "offline" + raise httpx.ConnectError(message, request=request) + + plugin = _plugin(monkeypatch, mock_send=False) + assert plugin.http_client is not None + await plugin.http_client.aclose() + plugin.http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + status = await plugin._send_to_ica({}) + await plugin.shutdown() + assert status == "failed" + + +@pytest.mark.asyncio +async def test_http_skips_without_client(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": False}, mock_send=False) + assert await plugin._send_to_ica({}) == "failed" + + +@pytest.mark.asyncio +async def test_http_skips_without_url(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "metering_token": "token"}, mock_send=False) + status = await plugin._send_to_ica({}) + await plugin.shutdown() + assert status == "skipped_no_url" + + +@pytest.mark.asyncio +async def test_http_skips_without_auth(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": True, "metering_url": "https://example.invalid"}, mock_send=False) + status = await plugin._send_to_ica({}) + await plugin.shutdown() + assert status == "skipped_no_auth" + + +def test_jwt_is_hs256(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + token = plugin._get_service_jwt(JWT_FIXTURE) + assert jwt.get_unverified_header(token)["alg"] == "HS256" + + +def test_jwt_subject_claim(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + claims = jwt.decode(plugin._get_service_jwt(JWT_FIXTURE), JWT_FIXTURE, algorithms=["HS256"]) + assert claims["sub"] == "contextforge-metering" + + +def test_jwt_service_claims(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + claims = jwt.decode(plugin._get_service_jwt(JWT_FIXTURE), JWT_FIXTURE, algorithms=["HS256"]) + assert claims["service"] == "mcp-context-forge" and claims["scope"] == "metering:write" + + +def test_jwt_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + claims = jwt.decode(plugin._get_service_jwt(JWT_FIXTURE), JWT_FIXTURE, algorithms=["HS256"]) + assert 86_300 <= claims["exp"] - claims["iat"] <= 86_500 + + +@pytest.mark.asyncio +async def test_post_metadata_requires_trace(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + result = await plugin.tool_post_invoke(_post(), _context(), _extensions(trace_id=None)) + assert result.metadata == {} + + +@pytest.mark.asyncio +async def test_post_metadata_with_trace_has_exact_keys(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + result = await plugin.tool_post_invoke(_post(), _context({"model_name": "session"}), _extensions()) + assert result.metadata is not None + metadata = result.metadata["ica_metering_exporter"] + assert set(metadata) == {"export_status", "latency_ms", "model_source", "stage"} + + +@pytest.mark.asyncio +async def test_post_metadata_never_contains_trace_id(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + result = await plugin.tool_post_invoke(_post(), _context(), _extensions(trace_id="unique-trace-sentinel")) + assert "unique-trace-sentinel" not in json.dumps(result.metadata) + + +@pytest.mark.asyncio +async def test_post_metadata_is_non_sensitive(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin( + monkeypatch, + {"enabled": True, "metering_token": "unique-token-sentinel", "jwt_secret": "unique-secret-sentinel"}, + ) + context = _context() + extensions = _extensions( + { + "X-App-Id": "unique-app-sentinel", + "User-Agent": "unique-agent-sentinel", + "assistant_name": "unique-persona-sentinel", + } + ) + await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={"value": "unique-argument-sentinel"}), context, extensions + ) + result = await plugin.tool_post_invoke(_post(result={"content": "unique-output-sentinel"}), context, extensions) + serialized = json.dumps(result.metadata) + for sentinel in ( + "unique-token", + "unique-secret", + "unique-app", + "unique-agent", + "unique-persona", + "unique-argument", + "unique-output", + ): + assert sentinel not in serialized + + +@pytest.mark.asyncio +async def test_post_accepts_extensions_none(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + result = await plugin.tool_post_invoke(_post(), _context(), None) + assert result.continue_processing is True and result.metadata == {} + + +def test_config_none_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = IcaMeteringExporterPlugin( + PluginConfig( + name="test", kind="cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin", hooks=[], config=None + ) + ) + assert plugin.telemetry_config == {} and plugin.http_client is None + + +def test_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {}) + assert plugin.http_client is None + + +def test_enabled_creates_http_client(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert isinstance(plugin.http_client, httpx.AsyncClient) + + +@pytest.mark.asyncio +async def test_shutdown_closes_client(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + client = plugin.http_client + assert client is not None + await plugin.shutdown() + assert client.is_closed and plugin.http_client is None + + +@pytest.mark.asyncio +async def test_shutdown_without_client(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch, {"enabled": False}) + await plugin.shutdown() + assert plugin.http_client is None + + +def test_get_header_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._get_header({"X-App-ID": "value"}, "x-app-id") == "value" + + +def test_get_header_handles_malformed_values(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._get_header({"X-App-ID": 123}, "x-app-id") == "123" + + +def test_coerce_int_valid(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._coerce_int("42") == 42 + + +def test_coerce_int_invalid(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._coerce_int([]) is None + + +def test_extract_tokens_valid(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._extract_tokens({"meta": {"tokens": {"input": 1}}}) == {"input": 1} + + +def test_extract_tokens_invalid(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._extract_tokens({"meta": []}) == {} + + +def test_is_error_various(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._is_error({"isError": True}) is True and plugin._is_error(None) is False + + +def test_extract_error_message(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = _plugin(monkeypatch) + assert plugin._extract_error_message({"isError": True, "errorMessage": "failed"}) == "failed" diff --git a/plugins/tests/conftest.py b/plugins/tests/conftest.py index 552a9c3..82c340a 100644 --- a/plugins/tests/conftest.py +++ b/plugins/tests/conftest.py @@ -8,7 +8,10 @@ TESTS_ROOT = Path(__file__).resolve().parent REPO_ROOT = TESTS_ROOT.parents[1] -PYTHON_PACKAGE_ROOT = REPO_ROOT / "plugins" / "rust" / "python-package" +PYTHON_PACKAGE_ROOTS = [ + REPO_ROOT / "plugins" / "python", + REPO_ROOT / "plugins" / "rust" / "python-package", +] selected_plugins = set() for arg in sys.argv[1:]: @@ -24,13 +27,20 @@ selected_plugins = { path.name for path in TESTS_ROOT.iterdir() - if path.is_dir() and (PYTHON_PACKAGE_ROOT / path.name).exists() + if path.is_dir() + and any((package_root / path.name).exists() for package_root in PYTHON_PACKAGE_ROOTS) } for slug in sorted(selected_plugins): - plugin_root = PYTHON_PACKAGE_ROOT / slug - if plugin_root.is_dir() and (plugin_root / "pyproject.toml").exists(): - sys.path.insert(0, str(plugin_root)) + for package_root in PYTHON_PACKAGE_ROOTS: + plugin_root = package_root / slug + if ( + plugin_root.is_dir() + and (plugin_root / "pyproject.toml").exists() + and (plugin_root / f"cpex_{slug}").is_dir() + ): + sys.path.insert(0, str(plugin_root)) + break if os.environ.get("CPEX_TEST_PLUGIN_HOOKS") != "1": raise RuntimeError( @@ -44,12 +54,18 @@ except ImportError: real_extensions = None +try: + from cpex.framework import constants as real_constants +except ImportError: + real_constants = None + cpex = types.ModuleType("cpex") framework = types.ModuleType("cpex.framework") hooks = types.ModuleType("cpex.framework.hooks") policies = types.ModuleType("cpex.framework.hooks.policies") memory = types.ModuleType("cpex.framework.memory") extensions_mod = types.ModuleType("cpex.framework.extensions") if real_extensions else None +constants_mod = types.ModuleType("cpex.framework.constants") if real_constants else None framework.__dict__.update(plugin_hooks.__dict__) policies.HookPayloadPolicy = plugin_hooks.HookPayloadPolicy @@ -57,6 +73,9 @@ memory.wrap_payload_for_isolation = plugin_hooks.wrap_payload_for_isolation if real_extensions and extensions_mod: extensions_mod.__dict__.update(real_extensions.__dict__) +if real_constants and constants_mod: + constants_mod.__dict__.update(real_constants.__dict__) + framework.constants = constants_mod sys.modules["cpex"] = cpex sys.modules["cpex.framework"] = framework @@ -67,3 +86,5 @@ sys.modules["cpex.framework.settings"] = plugin_hooks if extensions_mod: sys.modules["cpex.framework.extensions"] = extensions_mod +if constants_mod: + sys.modules["cpex.framework.constants"] = constants_mod diff --git a/plugins/tests/ica_metering_exporter/test_integration.py b/plugins/tests/ica_metering_exporter/test_integration.py new file mode 100644 index 0000000..fb1e28b --- /dev/null +++ b/plugins/tests/ica_metering_exporter/test_integration.py @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Plugin-framework integration tests for the ICA metering exporter. + +These tests pass ``Extensions`` directly to the hooks and therefore +intentionally bypass the gateway's ``read_headers`` capability filter; the +gateway registration must still grant that capability for attribution to +function in production. +""" + +from __future__ import annotations + +import copy +import sys +from collections.abc import AsyncIterator, Callable +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from cpex.framework import ( + PluginConfig, + PluginContext, + ToolPostInvokePayload, + ToolPreInvokePayload, +) +from cpex.framework.constants import GATEWAY_METADATA +from cpex.framework.extensions import Extensions, HttpExtension, RequestExtension +from cpex.framework.models import GlobalContext +from cpex_ica_metering_exporter.plugin import IcaMeteringExporterPlugin +from real_cpex_imports import assert_real_cpex_imports + +PLUGIN_KIND = "cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin" +HOOKS = ["tool_pre_invoke", "tool_post_invoke"] +METERING_URL = "https://metering.example.invalid/events" + + +def _make_config(**overrides: Any) -> PluginConfig: + config: dict[str, Any] = {"enabled": False} + config.update(overrides) + return PluginConfig( + name="ica_metering_exporter_test", + kind=PLUGIN_KIND, + hooks=HOOKS, + config=config, + ) + + +def _make_context(**global_kwargs: Any) -> PluginContext: + global_kwargs.setdefault("request_id", "req-ica-1") + global_kwargs.setdefault("server_id", "srv-ica-1") + return PluginContext(global_context=GlobalContext(**global_kwargs)) + + +def _make_extensions( + headers: dict[str, str], trace_id: str | None = None +) -> Extensions: + request = RequestExtension(trace_id=trace_id) if trace_id is not None else None + return Extensions(http=HttpExtension(headers=headers), request=request) + + +def _sent_payload(send: AsyncMock) -> dict[str, Any]: + send.assert_awaited_once() + await_args = send.await_args + assert await_args is not None + payload = await_args.args[0] + assert isinstance(payload, dict) + return payload + + +@pytest.fixture +async def make_plugin() -> AsyncIterator[Callable[..., IcaMeteringExporterPlugin]]: + """Construct plugins and close any HTTP clients after the test.""" + created: list[IcaMeteringExporterPlugin] = [] + + def _factory(**config: Any) -> IcaMeteringExporterPlugin: + plugin = IcaMeteringExporterPlugin(_make_config(**config)) + created.append(plugin) + return plugin + + yield _factory + + for plugin in created: + await plugin.shutdown() + + +def test_imports_with_real_cpex_package() -> None: + # Given the installed plugin package location. + plugin_root = ( + Path(__file__).resolve().parents[3] + / "plugins" + / "python" + / "ica_metering_exporter" + ) + + # When imports are resolved against the real cpex package in a subprocess. + # Then the plugin imports without the conftest shim, exercising the + # expanded real-cpex module tuple (constants and extensions included). + assert_real_cpex_imports( + plugin_root, + ["from cpex_ica_metering_exporter.plugin import IcaMeteringExporterPlugin"], + ) + + +def test_plugin_module_imports_through_constants_shim() -> None: + # Given the active conftest shim (gated by CPEX_TEST_PLUGIN_HOOKS=1). + # When the plugin package is imported through the shimmed cpex.framework.constants. + import cpex.framework.constants as constants_module + import cpex_ica_metering_exporter + + # Then the shim served GATEWAY_METADATA and exposed the plugin entry point. + assert GATEWAY_METADATA == "gateway" + assert sys.modules["cpex.framework.constants"] is constants_module + assert ( + cpex_ica_metering_exporter.IcaMeteringExporterPlugin + is IcaMeteringExporterPlugin + ) + + +@pytest.mark.asyncio +async def test_disabled_by_default_hooks_continue_without_send( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given a plugin with default configuration (disabled). + plugin = make_plugin() + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + send = plugin.__dict__["_send_to_ica"] + assert isinstance(send, AsyncMock) + context = _make_context() + extensions = _make_extensions({"X-App-Id": "app-1"}, trace_id="t-1") + + # When both hooks run. + pre_result = await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={}), context, extensions + ) + post_result = await plugin.tool_post_invoke( + ToolPostInvokePayload(name="tool", result={"isError": False}), + context, + extensions, + ) + + # Then both allow processing and no export or HTTP client was attempted. + assert pre_result.continue_processing is True + assert post_result.continue_processing is True + send.assert_not_called() + assert plugin.http_client is None + assert context.state == {} + + +@pytest.mark.asyncio +async def test_enabled_pre_post_invoke_exports_caller_attribution( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given an enabled plugin and the full caller-attribution header set. + plugin = make_plugin(enabled=True, metering_url=METERING_URL) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + send = plugin.__dict__["_send_to_ica"] + assert isinstance(send, AsyncMock) + context = _make_context() + extensions = _make_extensions( + { + "X-App-Id": "app-1", + "X-OpenWebUI-Model-Id": "gpt-4o", + "llm_call_type": "assistant", + "assistant_name": "Helper", + }, + trace_id="t-1", + ) + + # When the tool invocation flows through both hooks. + await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={}), context, extensions + ) + result = await plugin.tool_post_invoke( + ToolPostInvokePayload(name="tool", result={"isError": False}), + context, + extensions, + ) + + # Then the exported payload carries the header-derived attribution. + exported = _sent_payload(send) + assert exported["appId"] == "app-1" + assert exported["toolDetails"]["modelName"] == "gpt-4o" + assert exported["assistantName"] == "Helper" + assert result.metadata["ica_metering_exporter"]["export_status"] == "sent" + + +@pytest.mark.asyncio +async def test_headers_are_case_insensitive_via_extensions( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given a lower-case app id header on the extensions. + plugin = make_plugin(enabled=True, metering_url=METERING_URL) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + context = _make_context() + extensions = _make_extensions({"x-app-id": "lower-app-1"}, trace_id="t-1") + + # When pre-invoke reads the headers. + await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={}), context, extensions + ) + + # Then the lower-case header was honored. + assert context.state["ica_app_id"] == "lower-app-1" + + +@pytest.mark.asyncio +async def test_hooks_do_not_mutate_payloads( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given an enabled plugin and remembered payload contents. + plugin = make_plugin(enabled=True, metering_url=METERING_URL) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + context = _make_context() + extensions = _make_extensions({"X-App-Id": "app-1"}, trace_id="t-1") + pre_payload = ToolPreInvokePayload(name="tool", args={"query": "weather"}) + post_payload = ToolPostInvokePayload( + name="tool", result={"output": "sunny", "meta": {"tokens": {"input": 3}}} + ) + pre_snapshot = copy.deepcopy(pre_payload.args) + post_snapshot = copy.deepcopy(post_payload.result) + + # When both hooks run. + pre_result = await plugin.tool_pre_invoke(pre_payload, context, extensions) + post_result = await plugin.tool_post_invoke(post_payload, context, extensions) + + # Then neither hook replaced or mutated the payloads. + assert pre_result.modified_payload is None + assert post_result.modified_payload is None + assert pre_payload.args == pre_snapshot + assert post_payload.result == post_snapshot + + +@pytest.mark.asyncio +async def test_hooks_callable_without_extensions( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given an enabled plugin. + plugin = make_plugin(enabled=True, metering_url=METERING_URL) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + context = _make_context() + + # When both hooks are called with only (payload, context). + pre_result = await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={}), context + ) + post_result = await plugin.tool_post_invoke( + ToolPostInvokePayload(name="tool", result={}), context + ) + + # Then both succeed and no metadata is emitted without a trace. + assert pre_result.continue_processing is True + assert post_result.continue_processing is True + assert "ica_metering_exporter" not in (post_result.metadata or {}) diff --git a/plugins/tests/ica_metering_exporter/test_observability_contract.py b/plugins/tests/ica_metering_exporter/test_observability_contract.py new file mode 100644 index 0000000..f2ce14d --- /dev/null +++ b/plugins/tests/ica_metering_exporter/test_observability_contract.py @@ -0,0 +1,308 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Observability and security contract tests for the ICA metering exporter. + +Covers the OpenTelemetry metadata gating, the non-sensitivity of emitted +metadata and logs, falsy-input handling, and the non-mutating pluginConfig +overlay. ``Extensions`` are passed directly to the hooks, intentionally +bypassing the gateway's ``read_headers`` capability filter. +""" + +from __future__ import annotations + +import copy +import logging +from collections.abc import AsyncIterator, Callable +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from cpex.framework import ( + PluginConfig, + PluginContext, + ToolPostInvokePayload, + ToolPreInvokePayload, +) +from cpex.framework.constants import GATEWAY_METADATA +from cpex.framework.extensions import Extensions, HttpExtension, RequestExtension +from cpex.framework.models import GlobalContext +from cpex_ica_metering_exporter.plugin import IcaMeteringExporterPlugin + +PLUGIN_KIND = "cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin" +HOOKS = ["tool_pre_invoke", "tool_post_invoke"] +METERING_URL = "https://metering.example.invalid/events" +OPERATIONAL_METADATA_KEYS = {"export_status", "latency_ms", "model_source", "stage"} +SENTINELS = { + "app_id": "sentinel-app-9", + "user_agent": "sentinel-ua/9", + "persona": "sentinel-persona", + "payload": "sentinel-payload", + "token": "sentinel-token", + "jwt_secret": "sentinel-secret", + "trace_id": "sentinel-trace-9", +} + + +def _make_config(**overrides: Any) -> PluginConfig: + config: dict[str, Any] = {"enabled": False} + config.update(overrides) + return PluginConfig( + name="ica_metering_exporter_test", + kind=PLUGIN_KIND, + hooks=HOOKS, + config=config, + ) + + +def _make_context(**global_kwargs: Any) -> PluginContext: + global_kwargs.setdefault("request_id", "req-ica-1") + global_kwargs.setdefault("server_id", "srv-ica-1") + return PluginContext(global_context=GlobalContext(**global_kwargs)) + + +def _make_extensions( + headers: dict[str, str], trace_id: str | None = None +) -> Extensions: + request = RequestExtension(trace_id=trace_id) if trace_id is not None else None + return Extensions(http=HttpExtension(headers=headers), request=request) + + +def _flatten_strings(value: Any) -> list[str]: + """Recursively collect every string in a nested metadata structure.""" + if isinstance(value, str): + return [value] + if isinstance(value, dict): + collected: list[str] = [] + for key, item in value.items(): + collected.extend(_flatten_strings(key)) + collected.extend(_flatten_strings(item)) + return collected + if isinstance(value, (list, tuple, set, frozenset)): + items: list[str] = [] + for item in value: + items.extend(_flatten_strings(item)) + return items + return [] + + +def _sent_payload(send: AsyncMock) -> dict[str, Any]: + send.assert_awaited_once() + await_args = send.await_args + assert await_args is not None + payload = await_args.args[0] + assert isinstance(payload, dict) + return payload + + +@pytest.fixture +async def make_plugin() -> AsyncIterator[Callable[..., IcaMeteringExporterPlugin]]: + """Construct plugins and close any HTTP clients after the test.""" + created: list[IcaMeteringExporterPlugin] = [] + + def _factory(**config: Any) -> IcaMeteringExporterPlugin: + plugin = IcaMeteringExporterPlugin(_make_config(**config)) + created.append(plugin) + return plugin + + yield _factory + + for plugin in created: + await plugin.shutdown() + + +@pytest.mark.asyncio +async def test_otel_gating_omits_metadata_without_trace_id( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given an enabled plugin and extensions carrying no request trace. + plugin = make_plugin(enabled=True, metering_url=METERING_URL) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + send = plugin.__dict__["_send_to_ica"] + assert isinstance(send, AsyncMock) + context = _make_context() + extensions = _make_extensions({"X-App-Id": "app-1"}) + + # When post-invoke runs. + result = await plugin.tool_post_invoke( + ToolPostInvokePayload(name="tool", result={}), context, extensions + ) + + # Then the export happened but no exporter metadata was emitted. + send.assert_awaited_once() + assert "ica_metering_exporter" not in (result.metadata or {}) + + +@pytest.mark.asyncio +async def test_otel_gating_emits_metadata_with_trace_id( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given an enabled plugin and a request trace on the extensions. + plugin = make_plugin(enabled=True, metering_url=METERING_URL) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + context = _make_context() + extensions = _make_extensions( + {"X-OpenWebUI-Model-Id": "gpt-4o"}, trace_id="trace-integration-9" + ) + + # When the invocation flows through both hooks. + await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={}), context, extensions + ) + result = await plugin.tool_post_invoke( + ToolPostInvokePayload(name="tool", result={}), context, extensions + ) + + # Then metadata holds exactly the four operational keys and never the trace id. + metadata = result.metadata["ica_metering_exporter"] + assert set(metadata) == OPERATIONAL_METADATA_KEYS + assert metadata["export_status"] == "sent" + assert metadata["model_source"] == "transport_header" + assert metadata["stage"] == "tool_post_invoke" + assert isinstance(metadata["latency_ms"], int) + assert "trace-integration-9" not in _flatten_strings(result.metadata) + + +@pytest.mark.asyncio +async def test_metadata_and_logs_exclude_sensitive_sentinels( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + # Given an enabled plugin wired with unique sentinel credentials and caller data. + plugin = make_plugin( + enabled=True, + metering_url=METERING_URL, + metering_token=SENTINELS["token"], + jwt_secret=SENTINELS["jwt_secret"], + ) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + send = plugin.__dict__["_send_to_ica"] + assert isinstance(send, AsyncMock) + context = _make_context() + extensions = _make_extensions( + { + "X-App-Id": SENTINELS["app_id"], + "User-Agent": SENTINELS["user_agent"], + "assistant_name": SENTINELS["persona"], + }, + trace_id=SENTINELS["trace_id"], + ) + caplog.set_level(logging.DEBUG) + + # When an invocation carrying a sentinel-laden payload flows through both hooks. + await plugin.tool_pre_invoke( + ToolPreInvokePayload( + name="sentinel-tool", args={"input": SENTINELS["payload"]} + ), + context, + extensions, + ) + result = await plugin.tool_post_invoke( + ToolPostInvokePayload( + name="sentinel-tool", result={"output": SENTINELS["payload"]} + ), + context, + extensions, + ) + + # Then the exported payload proves the sentinels were processed. + exported = _sent_payload(send) + assert exported["appId"] == SENTINELS["app_id"] + assert exported["userAgent"] == SENTINELS["user_agent"] + assert exported["assistantName"] == SENTINELS["persona"] + + # And result metadata and logs contain none of the raw sentinel values. + assert set(result.metadata["ica_metering_exporter"]) == OPERATIONAL_METADATA_KEYS + flattened = _flatten_strings(result.metadata) + for label, sentinel in SENTINELS.items(): + assert sentinel not in flattened, ( + f"{label} sentinel leaked into result metadata" + ) + assert sentinel not in caplog.text, f"{label} sentinel leaked into logs" + + +@pytest.mark.asyncio +async def test_falsy_header_values_do_not_fabricate_attribution( + make_plugin: Callable[..., IcaMeteringExporterPlugin], + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given attribution headers that are present but carry empty values. + plugin = make_plugin(enabled=True, metering_url=METERING_URL) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + send = plugin.__dict__["_send_to_ica"] + assert isinstance(send, AsyncMock) + context = _make_context() + extensions = _make_extensions( + { + "X-App-Id": "", + "X-MCP-Client-Name": "", + "User-Agent": "", + "assistant_name": "", + }, + trace_id="t-1", + ) + + # When the invocation flows through both hooks. + await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={}), context, extensions + ) + await plugin.tool_post_invoke( + ToolPostInvokePayload(name="tool", result={}), context, extensions + ) + + # Then no attribution was fabricated from the empty values. + exported = _sent_payload(send) + assert exported["appId"] is None + assert exported["userAgent"] is None + assert exported["assistantName"] is None + assert "ica_app_id" not in context.state + + +@pytest.mark.asyncio +async def test_plugin_config_overlay_does_not_mutate_caller_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given a caller-owned configuration mapping with nested gateway entries. + config: dict[str, Any] = { + "enabled": True, + "metering_url": METERING_URL, + "gateways": [{"id": "gw-1", "default_model": "gpt-4o-mini"}], + "global_default_model": "gpt-4o", + } + snapshot = copy.deepcopy(config) + plugin = IcaMeteringExporterPlugin( + PluginConfig( + name="ica_metering_exporter_test", + kind=PLUGIN_KIND, + hooks=HOOKS, + config=config, + ) + ) + monkeypatch.setattr(plugin, "_send_to_ica", AsyncMock(return_value="sent")) + send = plugin.__dict__["_send_to_ica"] + assert isinstance(send, AsyncMock) + + try: + # When the plugin operates on the configuration, reading the gateway fallback. + context = _make_context( + metadata={GATEWAY_METADATA: {"id": "gw-1", "transport": "sse"}} + ) + extensions = _make_extensions({}, trace_id="t-1") + await plugin.tool_pre_invoke( + ToolPreInvokePayload(name="tool", args={}), context, extensions + ) + result = await plugin.tool_post_invoke( + ToolPostInvokePayload(name="tool", result={}), context, extensions + ) + + # Then the gateway default was honored through the overlay copy. + assert _sent_payload(send)["toolDetails"]["modelName"] == "gpt-4o-mini" + assert ( + result.metadata["ica_metering_exporter"]["model_source"] + == "gateway_default" + ) + assert plugin.telemetry_config is not config + assert config == snapshot + finally: + await plugin.shutdown() diff --git a/plugins/tests/plugin_hooks.py b/plugins/tests/plugin_hooks.py index d0991ce..72fd11b 100644 --- a/plugins/tests/plugin_hooks.py +++ b/plugins/tests/plugin_hooks.py @@ -133,6 +133,9 @@ class GlobalContext: server_id: str = "" user: Any = None tenant_id: str | None = None + state: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + user_context: Any = None @dataclass @@ -140,6 +143,19 @@ class PluginContext: plugin_id: str = "" global_context: GlobalContext = field(default_factory=GlobalContext) metadata: dict[str, Any] = field(default_factory=dict) + state: dict[str, Any] = field(default_factory=dict) + + @property + def user_email(self) -> str | None: + user_context = self.global_context.user_context + if user_context: + return user_context.email + user = self.global_context.user + if isinstance(user, str): + return user + if isinstance(user, dict): + return user.get("email") + return None @dataclass diff --git a/plugins/tests/real_cpex_imports.py b/plugins/tests/real_cpex_imports.py index dde2205..9efbafe 100644 --- a/plugins/tests/real_cpex_imports.py +++ b/plugins/tests/real_cpex_imports.py @@ -13,7 +13,11 @@ def assert_real_cpex_imports(plugin_root: Path, import_statements: list[str]) -> script = "\n".join( [ "import importlib", - "for name in ('cpex', 'cpex.framework', 'cpex.framework.models', 'cpex.framework.settings'):", + ( + "for name in ('cpex', 'cpex.framework', 'cpex.framework.models', " + "'cpex.framework.settings', 'cpex.framework.constants', " + "'cpex.framework.extensions'):" + ), " importlib.import_module(name)", *import_statements, "print('ok')", diff --git a/plugins/tests/test_harness_compatibility.py b/plugins/tests/test_harness_compatibility.py new file mode 100644 index 0000000..c7a17dc --- /dev/null +++ b/plugins/tests/test_harness_compatibility.py @@ -0,0 +1,197 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Compatibility tests for the repository integration-test harness.""" + +from __future__ import annotations + +import dataclasses +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Callable + +import pytest + +import plugin_hooks + + +REPO_ROOT = Path(__file__).resolve().parents[2] +TESTS_ROOT = Path(__file__).resolve().parent +UserContext = dataclasses.make_dataclass("UserContext", [("email", str)]) + + +def _run_harness(script: str, *, cwd: Path = REPO_ROOT) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["CPEX_TEST_PLUGIN_HOOKS"] = "1" + return subprocess.run( + [sys.executable, "-c", script], + cwd=cwd, + env=env, + text=True, + capture_output=True, + check=False, + ) + + +def test_fallback_prefers_python_package_root_when_both_roots_match(tmp_path: Path) -> None: + # Given a copied harness and the same plugin slug under both package roots. + tests_root = tmp_path / "plugins" / "tests" + tests_root.mkdir(parents=True) + shutil.copy2(TESTS_ROOT / "conftest.py", tests_root / "conftest.py") + shutil.copy2(TESTS_ROOT / "plugin_hooks.py", tests_root / "plugin_hooks.py") + (tests_root / "dual_root").mkdir() + for package_root in ( + tmp_path / "plugins" / "python", + tmp_path / "plugins" / "rust" / "python-package", + ): + plugin_root = package_root / "dual_root" + (plugin_root / "cpex_dual_root").mkdir(parents=True) + (plugin_root / "pyproject.toml").touch() + + script = f""" +import sys +from pathlib import Path +sys.argv = ["pytest"] +sys.path.insert(0, {str(tests_root)!r}) +import conftest +expected = [ + Path({str(tmp_path / "plugins" / "python")!r}), + Path({str(tmp_path / "plugins" / "rust" / "python-package")!r}), +] +assert conftest.PYTHON_PACKAGE_ROOTS == expected +assert Path(sys.path[0]) == expected[0] / "dual_root" +""" + + # When the copied conftest performs no-argument fallback discovery. + result = _run_harness(script, cwd=tmp_path) + + # Then Python is searched first and its matching package root is selected. + assert result.returncode == 0, result.stderr + + +def test_python_plugin_imports_through_constants_shim() -> None: + # Given pytest selection of the pure-Python ICA plugin. + script = f""" +import sys +from pathlib import Path +sys.argv = ["pytest", {str(TESTS_ROOT / "ica_metering_exporter" / "test_integration.py")!r}] +sys.path.insert(0, {str(TESTS_ROOT)!r}) +import conftest +import cpex.framework.constants as constants_module +from cpex.framework.constants import GATEWAY_METADATA +import cpex_ica_metering_exporter +assert GATEWAY_METADATA == "gateway" +assert constants_module is conftest.constants_mod +assert constants_module is not conftest.real_constants +assert Path(sys.path[0]) == Path({str(REPO_ROOT / "plugins" / "python" / "ica_metering_exporter")!r}) +""" + + # When conftest installs its framework shims in a fresh process. + result = _run_harness(script) + + # Then constants remains importable and the selected plugin imports cleanly. + assert result.returncode == 0, result.stderr + + +def test_context_fields_are_defaulted_and_isolated() -> None: + # Given two independently constructed shim contexts. + first_global = plugin_hooks.GlobalContext() + second_global = plugin_hooks.GlobalContext() + first_plugin = plugin_hooks.PluginContext() + second_plugin = plugin_hooks.PluginContext() + + # When one context's state and metadata are mutated. + first_global.state["global"] = 1 + first_global.metadata["metadata"] = 2 + first_plugin.state["plugin"] = 3 + + # Then defaults exist and no mutable value is shared. + assert first_global.user_context is None + assert second_global.state == {} + assert second_global.metadata == {} + assert second_plugin.state == {} + + +@pytest.mark.parametrize( + ("global_context_factory", "expected"), + [ + ( + lambda: plugin_hooks.GlobalContext( + user_context=UserContext(email="structured@example.test"), + user="legacy@example.test", + ), + "structured@example.test", + ), + ( + lambda: plugin_hooks.GlobalContext(user="string@example.test"), + "string@example.test", + ), + ( + lambda: plugin_hooks.GlobalContext( + user={"email": "dict@example.test"}, + ), + "dict@example.test", + ), + ( + lambda: plugin_hooks.GlobalContext( + user={"name": "anonymous"}, + ), + None, + ), + ], +) +def test_user_email_matches_framework_fallbacks( + global_context_factory: Callable[[], plugin_hooks.GlobalContext], + expected: str | None, +) -> None: + # Given each supported global user representation. + context = plugin_hooks.PluginContext(global_context=global_context_factory()) + + # When the shim resolves the user's email. + actual = context.user_email + + # Then structured, string, dict, and absent values match framework behavior. + assert actual == expected + + +@pytest.mark.parametrize( + ("slug", "selected_path"), + [ + ("ica_metering_exporter", "plugins/python/ica_metering_exporter"), + ("rate_limiter", "plugins/rust/python-package/rate_limiter"), + ], +) +def test_plugin_test_dry_run_selects_existing_language_root(slug: str, selected_path: str) -> None: + # Given a real plugin slug from either supported language root. + # When root Make routing is evaluated without executing sync or CI. + result = subprocess.run( + ["make", "-n", "plugin-test", f"PLUGIN={slug}"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + # Then the command selects exactly the plugin's existing directory. + assert result.returncode == 0, result.stderr + assert f"cd {selected_path} && make sync && make ci" in result.stdout + + +@pytest.mark.parametrize("slug", ["nonexistent_plugin", "../tests"]) +def test_plugin_test_rejects_unknown_or_malformed_slug_before_sync(slug: str) -> None: + # Given an unknown or path-traversing plugin slug. + # When the root plugin-test target is invoked. + result = subprocess.run( + ["make", "plugin-test", f"PLUGIN={slug}"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + # Then routing fails with the stable unknown-plugin message before sync. + output = result.stdout + result.stderr + assert result.returncode != 0 + assert f"Unknown plugin {slug}" in output + assert "uv sync" not in output diff --git a/pyproject.toml b/pyproject.toml index 947e55f..4b96422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,4 +29,5 @@ members = [ "plugins/rust/python-package/secrets_detection", "plugins/rust/python-package/sql_sanitizer", "plugins/rust/python-package/url_reputation", + "plugins/python/ica_metering_exporter", ] diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index 4aaa755..2fc582d 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -12,9 +12,13 @@ from pathlib import Path import re +from tools.plugin_catalog import CatalogError, discover_plugins + REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "tools" / "plugin_catalog.py" +CI_SELECTION_VALIDATOR = REPO_ROOT / "tools" / "validate_ci_selection.py" +JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"] def run_catalog(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: @@ -28,6 +32,13 @@ def run_catalog(*args: str, cwd: Path | None = None) -> subprocess.CompletedProc class PluginCatalogTests(unittest.TestCase): + def _assert_payload_contains( + self, + payload: dict[str, JsonValue], + expected: dict[str, JsonValue], + ) -> None: + self.assertEqual({key: payload[key] for key in expected}, expected) + def _extract_workflow_job_section(self, workflow: str, job_name: str) -> str: lines = workflow.splitlines() in_jobs = False @@ -120,6 +131,21 @@ def _extract_workflow_step_run( ) return "\n".join(run_lines) + "\n" + def _assert_workflow_needs_outputs_declared( + self, + workflow: str, + producer_job: str, + ) -> None: + job_section = self._extract_workflow_job_section(workflow, producer_job) + outputs_section = job_section.split(" outputs:\n", maxsplit=1)[1].split( + " steps:\n", maxsplit=1 + )[0] + declared = set(re.findall(r"^ ([a-z0-9_-]+):", outputs_section, re.MULTILINE)) + referenced = set( + re.findall(rf"needs\.{re.escape(producer_job)}\.outputs\.([a-z0-9_-]+)", workflow) + ) + self.assertEqual(referenced - declared, set()) + def _source_tree_has_extension(self, package_dir: Path, module_name: str) -> bool: return any(package_dir.glob(f"{module_name}*.so")) or any( package_dir.glob(f"{module_name}*.pyd") @@ -153,6 +179,43 @@ def _create_plugin(self, root: Path, slug: str) -> Path: ) return plugin_dir + def _create_python_plugin( + self, + root: Path, + slug: str, + *, + version: str = "0.2.0", + add_workspace_member: bool = True, + ) -> Path: + rust_root = root / "plugins" / "rust" / "python-package" + rust_root.mkdir(parents=True, exist_ok=True) + if not (root / "Cargo.toml").exists(): + (root / "Cargo.toml").write_text("[workspace]\nmembers = []\n") + + plugin_dir = root / "plugins" / "python" / slug + package_dir = plugin_dir / f"cpex_{slug}" + class_name = f"{slug.title().replace('_', '')}Plugin" + manifest_kind = f"cpex_{slug}.plugin.{class_name}" + package_dir.mkdir(parents=True) + (plugin_dir / "pyproject.toml").write_text( + f'[project]\nname = "cpex-{slug.replace("_", "-")}"\nversion = "{version}"\n\n' + '[project.entry-points."cpex.plugins"]\n' + f'{slug} = "cpex_{slug}.plugin:{class_name}"\n' + ) + (plugin_dir / "Makefile").write_text("all:\n\t@true\n") + (plugin_dir / "README.md").write_text(f"# {slug}\n") + (package_dir / "__init__.py").write_text("") + (package_dir / "plugin-manifest.yaml").write_text( + f'description: "{slug}"\nauthor: "ContextForge Team"\nversion: "{version}"\n' + f'kind: "{manifest_kind}"\navailable_hooks:\n - "tool_pre_invoke"\n' + ) + members = [f"plugins/python/{slug}"] if add_workspace_member else [] + rendered_members = ", ".join(json.dumps(member) for member in members) + (root / "pyproject.toml").write_text( + f"[tool.uv.workspace]\nmembers = [{rendered_members}]\n" + ) + return plugin_dir + def _parse_manifest_defaults(self, manifest_path: Path) -> dict[str, object]: defaults: dict[str, object] = {} in_defaults = False @@ -508,6 +571,7 @@ def test_repo_lists_all_managed_plugins(self) -> None: {entry["slug"] for entry in payload["plugins"]}, { "encoded_exfil_detection", + "ica_metering_exporter", "pii_filter", "rate_limiter", "retry_with_backoff", @@ -521,6 +585,7 @@ def test_repo_lists_all_managed_plugins(self) -> None: {slug: entry["module_name"] for slug, entry in by_slug.items()}, { "encoded_exfil_detection": "cpex_encoded_exfil_detection", + "ica_metering_exporter": "cpex_ica_metering_exporter", "pii_filter": "cpex_pii_filter", "rate_limiter": "cpex_rate_limiter", "retry_with_backoff": "cpex_retry_with_backoff", @@ -533,6 +598,7 @@ def test_repo_lists_all_managed_plugins(self) -> None: {slug: entry["kind"] for slug, entry in by_slug.items()}, { "encoded_exfil_detection": "cpex_encoded_exfil_detection.encoded_exfil_detection.EncodedExfilDetectorPlugin", + "ica_metering_exporter": "cpex_ica_metering_exporter.plugin.IcaMeteringExporterPlugin", "pii_filter": "cpex_pii_filter.pii_filter.PIIFilterPlugin", "rate_limiter": "cpex_rate_limiter.rate_limiter.RateLimiterPlugin", "retry_with_backoff": "cpex_retry_with_backoff.retry_with_backoff.RetryWithBackoffPlugin", @@ -1656,6 +1722,10 @@ def test_release_info_field_supports_kind(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), "cpex_pii_filter.pii_filter.PIIFilterPlugin") + result = run_catalog("release-info-field", str(REPO_ROOT), tag, "language") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "rust") + def test_ci_selection_returns_has_plugins_contract(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -1685,20 +1755,17 @@ def test_ci_selection_returns_has_plugins_contract(self) -> None: result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter", "rate_limiter"], - "has_plugins": True, - "plugin_count": 2, - "cargo_packages": ["pii_filter", "rate_limiter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], - "release_validation_tags": [], - "has_release_validation_tags": False, - }, - ) + self._assert_payload_contains(payload, { + "plugins": ["pii_filter", "rate_limiter"], + "has_plugins": True, + "plugin_count": 2, + "cargo_packages": ["pii_filter", "rate_limiter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], + "release_validation_tags": [], + "has_release_validation_tags": False, + }) def test_ci_selection_detects_plugin_version_bump_for_release_validation(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1805,20 +1872,17 @@ def test_ci_selection_treats_catalog_test_change_as_not_shared(self) -> None: result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": [], - "has_plugins": False, - "plugin_count": 0, - "cargo_packages": [], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": [], + "has_plugins": False, + "plugin_count": 0, + "cargo_packages": [], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_ci_selection_treats_shared_tool_changes_as_all_plugins(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1851,20 +1915,17 @@ def test_ci_selection_treats_shared_tool_changes_as_all_plugins(self) -> None: result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter", "rate_limiter"], - "has_plugins": True, - "plugin_count": 2, - "cargo_packages": ["pii_filter", "rate_limiter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": ["pii_filter", "rate_limiter"], + "has_plugins": True, + "plugin_count": 2, + "cargo_packages": ["pii_filter", "rate_limiter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_ci_selection_treats_tooling_config_changes_as_all_plugins(self) -> None: for config_path in (".cargo/mutants.toml", ".config/nextest.toml"): @@ -1899,20 +1960,17 @@ def test_ci_selection_treats_tooling_config_changes_as_all_plugins(self) -> None result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter", "rate_limiter"], - "has_plugins": True, - "plugin_count": 2, - "cargo_packages": ["pii_filter", "rate_limiter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": ["pii_filter", "rate_limiter"], + "has_plugins": True, + "plugin_count": 2, + "cargo_packages": ["pii_filter", "rate_limiter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_ci_selection_skips_mutation_for_unrelated_tooling_config(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1979,20 +2037,17 @@ def test_ci_selection_treats_cargo_lock_change_as_all_plugins(self) -> None: result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter", "rate_limiter"], - "has_plugins": True, - "plugin_count": 2, - "cargo_packages": ["pii_filter", "rate_limiter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": ["pii_filter", "rate_limiter"], + "has_plugins": True, + "plugin_count": 2, + "cargo_packages": ["pii_filter", "rate_limiter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_ci_selection_treats_root_python_workspace_change_as_all_plugins(self) -> None: for config_path in ("pyproject.toml", "uv.lock"): @@ -2111,20 +2166,17 @@ def test_ci_selection_treats_deny_config_change_as_all_plugins(self) -> None: result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter", "rate_limiter"], - "has_plugins": True, - "plugin_count": 2, - "cargo_packages": ["pii_filter", "rate_limiter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": ["pii_filter", "rate_limiter"], + "has_plugins": True, + "plugin_count": 2, + "cargo_packages": ["pii_filter", "rate_limiter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_changed_returns_plugin_for_plugin_integration_test_change(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -2158,20 +2210,17 @@ def test_changed_returns_plugin_for_plugin_integration_test_change(self) -> None result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter"], - "has_plugins": True, - "plugin_count": 1, - "cargo_packages": ["pii_filter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": ["pii_filter"], + "has_plugins": True, + "plugin_count": 1, + "cargo_packages": ["pii_filter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_ci_selection_treats_shared_plugin_tests_change_as_all_plugins(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -2205,20 +2254,17 @@ def test_ci_selection_treats_shared_plugin_tests_change_as_all_plugins(self) -> result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter", "rate_limiter"], - "has_plugins": True, - "plugin_count": 2, - "cargo_packages": ["pii_filter", "rate_limiter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": ["pii_filter", "rate_limiter"], + "has_plugins": True, + "plugin_count": 2, + "cargo_packages": ["pii_filter", "rate_limiter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_ci_selection_treats_shared_crate_changes_as_all_plugins(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -2256,26 +2302,23 @@ def test_ci_selection_treats_shared_crate_changes_as_all_plugins(self) -> None: result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["pii_filter", "rate_limiter"], - "has_plugins": True, - "plugin_count": 2, - "cargo_packages": ["pii_filter", "rate_limiter"], - "mutation_cargo_packages": ["cpex_framework_bridge"], - "has_mutation_cargo_packages": True, - "mutation_jobs": [ - { - "cargo_package": "cpex_framework_bridge", - "in_diff": True, - "test_packages": ["rate_limiter"], - } - ], - "release_validation_tags": [], - "has_release_validation_tags": False, - }, - ) + self._assert_payload_contains(payload, { + "plugins": ["pii_filter", "rate_limiter"], + "has_plugins": True, + "plugin_count": 2, + "cargo_packages": ["pii_filter", "rate_limiter"], + "mutation_cargo_packages": ["cpex_framework_bridge"], + "has_mutation_cargo_packages": True, + "mutation_jobs": [ + { + "cargo_package": "cpex_framework_bridge", + "in_diff": True, + "test_packages": ["rate_limiter"], + } + ], + "release_validation_tags": [], + "has_release_validation_tags": False, + }) def test_ci_selection_ignores_tooling_config_for_shared_crate_mutation_jobs(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -2433,6 +2476,9 @@ def test_framework_bridge_mutation_job_uses_real_dependents(self) -> None: shutil.copytree(REPO_ROOT / "plugins", root / "plugins") shutil.copytree(REPO_ROOT / "crates", root / "crates") (root / "Cargo.toml").write_text((REPO_ROOT / "Cargo.toml").read_text()) + (root / "pyproject.toml").write_text( + (REPO_ROOT / "pyproject.toml").read_text() + ) subprocess.run( ["git", "add", "."], cwd=root, @@ -2522,20 +2568,17 @@ def test_ci_selection_reports_cargo_packages_for_single_plugin_diff(self) -> Non result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["rate_limiter"], - "has_plugins": True, - "plugin_count": 1, - "cargo_packages": ["rate_limiter"], - "mutation_cargo_packages": [], - "has_mutation_cargo_packages": False, - "mutation_jobs": [], + self._assert_payload_contains(payload, { + "plugins": ["rate_limiter"], + "has_plugins": True, + "plugin_count": 1, + "cargo_packages": ["rate_limiter"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], "release_validation_tags": [], "has_release_validation_tags": False, - }, - ) + }) def test_ci_selection_reports_mutation_package_for_single_rust_diff(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -2570,25 +2613,22 @@ def test_ci_selection_reports_mutation_package_for_single_rust_diff(self) -> Non result = run_catalog("ci-selection", str(root), "diff", base_sha, "HEAD") self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) - self.assertEqual( - payload, - { - "plugins": ["rate_limiter"], - "has_plugins": True, - "plugin_count": 1, - "cargo_packages": ["rate_limiter"], - "mutation_cargo_packages": ["rate_limiter"], - "has_mutation_cargo_packages": True, - "mutation_jobs": [ - {"cargo_package": "rate_limiter", "in_diff": True, "test_packages": []} - ], - "release_validation_tags": [], - "has_release_validation_tags": False, - }, - ) + self._assert_payload_contains(payload, { + "plugins": ["rate_limiter"], + "has_plugins": True, + "plugin_count": 1, + "cargo_packages": ["rate_limiter"], + "mutation_cargo_packages": ["rate_limiter"], + "has_mutation_cargo_packages": True, + "mutation_jobs": [ + {"cargo_package": "rate_limiter", "in_diff": True, "test_packages": []} + ], + "release_validation_tags": [], + "has_release_validation_tags": False, + }) def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: - expected_plugins = [ + expected_rust_plugins = [ "encoded_exfil_detection", "pii_filter", "rate_limiter", @@ -2597,6 +2637,11 @@ def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: "sql_sanitizer", "url_reputation", ] + expected_plugins = [ + "encoded_exfil_detection", + "ica_metering_exporter", + *expected_rust_plugins[1:], + ] result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "plugins") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout), expected_plugins) @@ -2607,15 +2652,15 @@ def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "plugin_count") self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "7") + self.assertEqual(result.stdout.strip(), "8") result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "cargo_packages") self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(json.loads(result.stdout), expected_plugins) + self.assertEqual(json.loads(result.stdout), expected_rust_plugins) result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "mutation_cargo_packages") self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(json.loads(result.stdout), expected_plugins) + self.assertEqual(json.loads(result.stdout), expected_rust_plugins) result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "mutation_jobs") self.assertEqual(result.returncode, 0, result.stderr) @@ -2623,7 +2668,7 @@ def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: json.loads(result.stdout), [ {"cargo_package": plugin, "in_diff": False, "test_packages": []} - for plugin in expected_plugins + for plugin in expected_rust_plugins ], ) @@ -2639,6 +2684,26 @@ def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), "false") + expected_split_fields = { + "rust_plugins": json.dumps(expected_rust_plugins), + "python_plugins": '["ica_metering_exporter"]', + "has_rust_plugins": "true", + "has_python_plugins": "true", + "rust_plugin_count": "7", + "python_plugin_count": "1", + "rust_release_validation_tags": "[]", + "python_release_validation_tags": "[]", + "has_rust_release_validation_tags": "false", + "has_python_release_validation_tags": "false", + } + for field, expected in expected_split_fields.items(): + with self.subTest(field=field): + result = run_catalog( + "ci-selection-field", str(REPO_ROOT), "all", "", "", field + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), expected) + def test_ci_selection_field_supports_diff_mode(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -2736,6 +2801,11 @@ def test_catalog_workflow_paths_match_contract(self) -> None: ".github/workflows/ci-plugin-catalog.yaml", ".github/workflows/ci-rust-python-package.yaml", ".github/workflows/release-rust-python-package.yaml", + ".github/workflows/ci-python-package.yaml", + ".github/workflows/release-python-package.yaml", + "plugins/python/**", + "tools/validate_ci_selection.py", + "pyproject.toml", } actual_paths = { match.group(1) @@ -2863,7 +2933,7 @@ def test_ci_workflow_uses_make_targets_for_plugin_checks(self) -> None: self.assertIn('- "Makefile"', workflow) self.assertNotIn("pulls?state=open&head=", workflow) self.assertNotIn("dedupe:", workflow) - self.assertIn("if: needs.validate-and-detect.outputs.has_plugins == 'true'", workflow) + self.assertIn("if: needs.validate-and-detect.outputs.has_rust_plugins == 'true'", workflow) self.assertNotIn("tests/test_plugin_catalog.py", workflow) self.assertNotIn("tests/test_install_built_wheel.py", workflow) self.assertIn("python3 tools/plugin_catalog.py ci-selection . diff", workflow) @@ -2901,6 +2971,229 @@ def test_ci_workflow_uses_make_targets_for_plugin_checks(self) -> None: r"defaults:\n\s+run:\n\s+shell: bash\n\s+working-directory: .*\$\{\{", ) + def test_ci_workflows_route_complete_language_split_contracts(self) -> None: + rust_workflow = ( + REPO_ROOT / ".github" / "workflows" / "ci-rust-python-package.yaml" + ).read_text() + python_workflow = ( + REPO_ROOT / ".github" / "workflows" / "ci-python-package.yaml" + ).read_text() + + self._assert_workflow_needs_outputs_declared( + rust_workflow, "validate-and-detect" + ) + self._assert_workflow_needs_outputs_declared( + python_workflow, "validate-and-detect" + ) + for field in ( + "rust_plugins", + "has_rust_plugins", + "rust_plugin_count", + "rust_release_validation_tags", + "has_rust_release_validation_tags", + ): + self.assertIn(f"echo \"{field}=", rust_workflow) + self.assertIn( + f"{field}: ${{{{ steps.detect.outputs.{field} }}}}", rust_workflow + ) + for field in ( + "python_plugins", + "has_python_plugins", + "python_plugin_count", + "python_release_validation_tags", + "has_python_release_validation_tags", + ): + self.assertIn(f"echo \"{field}=", python_workflow) + self.assertIn( + f"{field}: ${{{{ steps.detect.outputs.{field} }}}}", python_workflow + ) + + self.assertNotIn('- "plugins/python/**"', rust_workflow) + self.assertIn("fromJson(needs.validate-and-detect.outputs.rust_plugins)", rust_workflow) + self.assertIn("fromJson(needs.validate-and-detect.outputs.python_plugins)", python_workflow) + self.assertIn( + "working-directory: plugins/python/${{ matrix.plugin }}", python_workflow + ) + + def test_python_ci_release_validation_and_tag_creation_match_contract(self) -> None: + workflow = ( + REPO_ROOT / ".github" / "workflows" / "ci-python-package.yaml" + ).read_text() + expected_paths = { + "Makefile", + "pyproject.toml", + "uv.lock", + "plugins/python/**", + "plugins/tests/**", + "tools/**", + "tests/**", + ".github/workflows/ci-python-package.yaml", + ".github/workflows/release-python-package.yaml", + } + for path in expected_paths: + self.assertEqual(workflow.count(f'- "{path}"'), 2) + + release_validation = self._extract_workflow_job_section( + workflow, "release-validation" + ) + create_tags = self._extract_workflow_job_section( + workflow, "create-release-tags" + ) + self.assertIn("uses: ./.github/workflows/release-python-package.yaml", release_validation) + self.assertIn("has_python_release_validation_tags == 'true'", release_validation) + self.assertIn( + "fromJson(needs.validate-and-detect.outputs.python_release_validation_tags)", + release_validation, + ) + self.assertIn("repository: testpypi", release_validation) + self.assertIn("publish_enabled: false", release_validation) + for dependency in ("validate-and-detect", "build-test", "release-validation"): + self.assertIn(f" - {dependency}", create_tags) + for rust_only_job in ( + "security-policy", + "mutation-testing", + "coverage", + "documentation", + ): + self.assertNotIn(rust_only_job, create_tags) + self.assertIn("always()", create_tags) + self.assertIn("github.event_name == 'push'", create_tags) + self.assertIn("github.ref == 'refs/heads/main'", create_tags) + self.assertIn("has_python_release_validation_tags == 'true'", create_tags) + self.assertIn("needs.build-test.result == 'success'", create_tags) + self.assertIn("needs.release-validation.result == 'skipped'", create_tags) + self.assertIn('git tag "${tag}" "${GITHUB_SHA}"', create_tags) + self.assertIn('git push origin "refs/tags/${tag}"', create_tags) + self.assertIn("gh workflow run release-python-package.yaml", create_tags) + + def test_release_workflows_guard_every_post_resolve_job(self) -> None: + workflows = { + "rust": ( + REPO_ROOT + / ".github" + / "workflows" + / "release-rust-python-package.yaml" + ).read_text(), + "python": ( + REPO_ROOT / ".github" / "workflows" / "release-python-package.yaml" + ).read_text(), + } + jobs = { + "rust": ("preflight", "build-wheel", "build-sdist", "publish"), + "python": ("build", "test-built-wheel", "test-built-sdist", "publish"), + } + for language, workflow in workflows.items(): + with self.subTest(language=language): + self._assert_workflow_needs_outputs_declared(workflow, "resolve") + resolve_section = self._extract_workflow_job_section(workflow, "resolve") + self.assertIn("skip: ${{ steps.resolve.outputs.skip }}", resolve_section) + self.assertIn(f'"${{language}}" != "{language}"', resolve_section) + self.assertIn('echo "skip=true"', resolve_section) + self.assertIn('echo "skip=false"', resolve_section) + for job_name in jobs[language]: + job_section = self._extract_workflow_job_section(workflow, job_name) + self.assertIn("needs.resolve.outputs.skip != 'true'", job_section) + + def test_python_release_artifact_handoff_and_publish_topology(self) -> None: + workflow = ( + REPO_ROOT / ".github" / "workflows" / "release-python-package.yaml" + ).read_text() + build = self._extract_workflow_job_section(workflow, "build") + wheel = self._extract_workflow_job_section(workflow, "test-built-wheel") + sdist = self._extract_workflow_job_section(workflow, "test-built-sdist") + publish = self._extract_workflow_job_section(workflow, "publish") + + self.assertIn("push:\n tags:\n - \"*-v*\"", workflow) + self.assertIn("workflow_call:", workflow) + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("run: uv build --out-dir dist", build) + self.assertIn("name: python-dist", build) + self.assertIn("path: ${{ needs.resolve.outputs.plugin_path }}/dist/", build) + self.assertIn("if-no-files-found: error", build) + for section, artifact_glob in ( + (wheel, "dist/*.whl"), + (sdist, "dist/*.tar.gz"), + ): + self.assertIn("needs: [resolve, build]", section) + self.assertIn("ref: ${{ needs.resolve.outputs.checkout_ref }}", section) + self.assertIn( + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1", + section, + ) + self.assertIn("name: python-dist", section) + self.assertIn("path: ${{ needs.resolve.outputs.plugin_path }}/dist", section) + self.assertIn( + "working-directory: ${{ needs.resolve.outputs.plugin_path }}", section + ) + self.assertIn(f'"${{venv_python}}" -m pip install {artifact_glob}', section) + self.assertIn( + "needs: [resolve, build, test-built-wheel, test-built-sdist]", publish + ) + self.assertIn( + "needs.resolve.outputs.skip != 'true' && needs.resolve.outputs.publish_enabled == 'true'", + publish, + ) + self.assertIn( + "needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true'", + publish, + ) + + def test_plugin_maintenance_runs_rust_and_python_plugin_lists(self) -> None: + workflow = ( + REPO_ROOT / ".github" / "workflows" / "plugin-maintenance.yaml" + ).read_text() + run = self._extract_workflow_step_run( + workflow, + "update-and-test", + step_name="Test all plugins", + ) + arrays = { + name: re.search( + rf"(?m)^{name}=\(\n(?P(?: [a-z0-9_]+\n)+)\)$", + run, + ) + for name in ("rust_plugins", "python_plugins") + } + for name, match in arrays.items(): + self.assertIsNotNone(match, f"expected shell array {name!r}") + + rust_plugins = re.findall(r"(?m)^ ([a-z0-9_]+)$", arrays["rust_plugins"].group("body")) + python_plugins = re.findall( + r"(?m)^ ([a-z0-9_]+)$", + arrays["python_plugins"].group("body"), + ) + self.assertEqual( + rust_plugins, + [ + "encoded_exfil_detection", + "pii_filter", + "rate_limiter", + "retry_with_backoff", + "secrets_detection", + "sql_sanitizer", + "url_reputation", + ], + ) + self.assertEqual(python_plugins, ["ica_metering_exporter"]) + + rust_loop = re.search( + r'(?ms)^for plugin in "\$\{rust_plugins\[@\]\}"; do\n(?P.+?)^done$', + run, + ) + python_loop = re.search( + r'(?ms)^for plugin in "\$\{python_plugins\[@\]\}"; do\n(?P.+?)^done$', + run, + ) + self.assertIsNotNone(rust_loop, "expected rust_plugins loop") + self.assertIsNotNone(python_loop, "expected python_plugins loop") + self.assertIn('pushd "plugins/rust/python-package/${plugin}"', rust_loop.group("body")) + self.assertNotIn('pushd "plugins/python/${plugin}"', rust_loop.group("body")) + self.assertIn('pushd "plugins/python/${plugin}"', python_loop.group("body")) + self.assertNotIn( + 'pushd "plugins/rust/python-package/${plugin}"', + python_loop.group("body"), + ) + def test_catalog_workflow_runs_catalog_suite(self) -> None: workflow = ( REPO_ROOT / ".github" / "workflows" / "ci-plugin-catalog.yaml" @@ -2995,14 +3288,20 @@ def test_ci_workflow_includes_parity_jobs_for_rust_plugin_checks(self) -> None: "ci-selection . diff \"${{ github.event.pull_request.base.sha }}\" \"${{ github.event.pull_request.head.sha }}\"", detect_run, ) - self.assertIn("plugin_count: ${{ steps.detect.outputs.plugin_count }}", workflow) + self.assertIn("rust_plugin_count: ${{ steps.detect.outputs.rust_plugin_count }}", workflow) self.assertNotIn("single_cargo_package", workflow) self.assertIn("cargo_packages: ${{ steps.detect.outputs.cargo_packages }}", workflow) self.assertIn("mutation_cargo_packages: ${{ steps.detect.outputs.mutation_cargo_packages }}", workflow) self.assertIn("mutation_jobs: ${{ steps.detect.outputs.mutation_jobs }}", workflow) self.assertIn("has_mutation_cargo_packages: ${{ steps.detect.outputs.has_mutation_cargo_packages }}", workflow) - self.assertIn("release_validation_tags: ${{ steps.detect.outputs.release_validation_tags }}", workflow) - self.assertIn("has_release_validation_tags: ${{ steps.detect.outputs.has_release_validation_tags }}", workflow) + self.assertIn( + "rust_release_validation_tags: ${{ steps.detect.outputs.rust_release_validation_tags }}", + workflow, + ) + self.assertIn( + "has_rust_release_validation_tags: ${{ steps.detect.outputs.has_rust_release_validation_tags }}", + workflow, + ) self.assertIn("security-policy:", workflow) self.assertIn("mutation-testing:", workflow) self.assertIn("coverage:", workflow) @@ -3010,10 +3309,10 @@ def test_ci_workflow_includes_parity_jobs_for_rust_plugin_checks(self) -> None: self.assertNotIn("benchmark-build-verification:", workflow) self.assertIn("if: needs.validate-and-detect.outputs.has_plugins == 'true'", security_section) self.assertIn("if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_mutation_cargo_packages == 'true'", mutants_section) - self.assertIn("if: needs.validate-and-detect.outputs.has_plugins == 'true'", coverage_section) + self.assertIn("if: needs.validate-and-detect.outputs.has_rust_plugins == 'true'", coverage_section) self.assertIn("if: needs.validate-and-detect.outputs.has_plugins == 'true'", documentation_section) - self.assertIn("if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_release_validation_tags == 'true'", release_validation_section) - self.assertIn("tag: ${{ fromJson(needs.validate-and-detect.outputs.release_validation_tags) }}", release_validation_section) + self.assertIn("if: github.event_name == 'pull_request' && needs.validate-and-detect.outputs.has_rust_release_validation_tags == 'true'", release_validation_section) + self.assertIn("tag: ${{ fromJson(needs.validate-and-detect.outputs.rust_release_validation_tags) }}", release_validation_section) self.assertIn("github.event_name == 'push'", create_tags_section) self.assertIn("github.ref == 'refs/heads/main'", create_tags_section) self.assertIn("always()", create_tags_section) @@ -3081,7 +3380,7 @@ def test_ci_workflow_includes_parity_jobs_for_rust_plugin_checks(self) -> None: self.assertIn("PYO3_PYTHON: python", mutants_section) self.assertIn("python -m pip install uv==0.9.30 maturin==1.12.6", coverage_section) self.assertIn("CARGO_PACKAGES: ${{ needs.validate-and-detect.outputs.cargo_packages }}", coverage_section) - self.assertIn("PLUGINS: ${{ needs.validate-and-detect.outputs.plugins }}", coverage_section) + self.assertIn("PLUGINS: ${{ needs.validate-and-detect.outputs.rust_plugins }}", coverage_section) self.assertIn('os.environ["CARGO_PACKAGES"]', coverage_run) self.assertIn('os.environ["PLUGINS"]', coverage_run) self.assertIn('cargo_args+=("-p" "${package}")', coverage_run) @@ -3159,8 +3458,8 @@ def test_ci_workflow_dispatch_detect_step_selects_all_plugins(self) -> None: if "=" in line ) self.assertEqual(outputs["has_plugins"], "true") - self.assertEqual(outputs["plugin_count"], "7") - expected_plugins = [ + self.assertEqual(outputs["plugin_count"], "8") + expected_rust_plugins = [ "encoded_exfil_detection", "pii_filter", "rate_limiter", @@ -3169,14 +3468,21 @@ def test_ci_workflow_dispatch_detect_step_selects_all_plugins(self) -> None: "sql_sanitizer", "url_reputation", ] + expected_plugins = [ + "encoded_exfil_detection", + "ica_metering_exporter", + *expected_rust_plugins[1:], + ] self.assertEqual(json.loads(outputs["plugins"]), expected_plugins) - self.assertEqual(json.loads(outputs["cargo_packages"]), expected_plugins) - self.assertEqual(json.loads(outputs["mutation_cargo_packages"]), expected_plugins) + self.assertEqual(json.loads(outputs["cargo_packages"]), expected_rust_plugins) + self.assertEqual( + json.loads(outputs["mutation_cargo_packages"]), expected_rust_plugins + ) self.assertEqual( json.loads(outputs["mutation_jobs"]), [ {"cargo_package": plugin, "in_diff": False, "test_packages": []} - for plugin in expected_plugins + for plugin in expected_rust_plugins ], ) self.assertEqual(outputs["has_mutation_cargo_packages"], "true") @@ -3648,7 +3954,7 @@ def test_release_workflow_tests_artifacts_outside_source_tree(self) -> None: self.assertNotIn("allow_non_main_pypi", workflow) self.assertNotIn("ALLOW_NON_MAIN_PYPI", workflow) self.assertIn( - "if: ${{ needs.resolve.outputs.publish_enabled == 'true' && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }}", + "if: ${{ needs.resolve.outputs.skip != 'true' && needs.resolve.outputs.publish_enabled == 'true' && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }}", workflow, ) self.assertNotIn("matrix.", preflight_section) @@ -3980,6 +4286,474 @@ def test_secrets_detection_keeps_scanner_module_internal(self) -> None: self.assertIn("use secrets_detection_rust::detect_and_redact;", bench_rs) self.assertNotIn("use secrets_detection_rust::scanner::detect_and_redact;", bench_rs) + def test_python_plugin_discovery_reports_typed_language_records(self) -> None: + # Given a repository containing one Rust and one pure-Python plugin. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + rust_path = "plugins/rust/python-package/rust_demo" + (root / "Cargo.toml").write_text( + f'[workspace]\nmembers = ["{rust_path}"]\n' + '[workspace.package]\nrepository = "https://github.com/IBM/cpex-plugins"\n' + ) + self._create_plugin(root, "rust_demo") + self._create_python_plugin(root, "python_demo") + + # When the real catalog lists managed plugins. + result = run_catalog("list", str(root)) + + # Then both roots are discovered with language-specific Cargo metadata. + self.assertEqual(result.returncode, 0, result.stderr) + discovered = json.loads(result.stdout)["plugins"] + self.assertEqual( + [record["slug"] for record in discovered], ["rust_demo", "python_demo"] + ) + records = {record["slug"]: record for record in discovered} + self.assertEqual(records["rust_demo"]["language"], "rust") + self.assertEqual(records["rust_demo"]["cargo_package_name"], "rust_demo") + self.assertEqual(records["python_demo"]["language"], "python") + self.assertIsNone(records["python_demo"]["cargo_package_name"]) + + def test_discovery_rejects_duplicate_slug_across_managed_roots(self) -> None: + # Given valid Rust and pure-Python plugins with the same slug. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + slug = "shared_demo" + rust_path = f"plugins/rust/python-package/{slug}" + (root / "Cargo.toml").write_text( + f'[workspace]\nmembers = ["{rust_path}"]\n' + '[workspace.package]\nrepository = "https://github.com/IBM/cpex-plugins"\n' + ) + self._create_plugin(root, slug) + self._create_python_plugin(root, slug) + + # When discovery crosses the second managed root, then ambiguity is rejected. + with self.assertRaisesRegex(CatalogError, slug) as raised: + discover_plugins(root) + + self.assertIn("across managed roots", str(raised.exception)) + self.assertIn(rust_path, str(raised.exception)) + self.assertIn(f"plugins/python/{slug}", str(raised.exception)) + + def test_python_plugin_validates_without_cargo_workspace_membership(self) -> None: + # Given a valid Python plugin that is only a root uv workspace member. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + self._create_python_plugin(root, "python_demo") + + # When catalog validation runs. + result = run_catalog("validate", str(root)) + + # Then the absent Cargo workspace membership is accepted. + self.assertEqual(result.returncode, 0, result.stderr) + + def test_python_validator_rejects_cargo_workspace_membership(self) -> None: + # Given a Python plugin incorrectly added to the Cargo workspace. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + self._create_python_plugin(root, "python_demo") + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["plugins/python/python_demo"]\n' + ) + + # When validation runs. + result = run_catalog("validate", str(root)) + + # Then language ownership prevents Cargo from claiming the Python plugin. + self.assertNotEqual(result.returncode, 0) + self.assertIn("must not be cargo workspace members", result.stderr.lower()) + + def test_python_validator_rejects_invalid_project_metadata(self) -> None: + cases = { + "static project.version": ( + '[project]\nname = "cpex-python-demo"\n', + True, + ), + "dynamic": ( + ( + '[project]\nname = "cpex-python-demo"\nversion = "0.2.0"\n' + 'dynamic = ["version"]\n' + ), + True, + ), + "package name": ( + '[project]\nname = "wrong-name"\nversion = "0.2.0"\n', + False, + ), + "entry-points": ( + '[project]\nname = "cpex-python-demo"\nversion = "0.2.0"\n', + False, + ), + } + for expected_error, (project_table, include_entry_point) in cases.items(): + with self.subTest( + expected_error=expected_error + ), tempfile.TemporaryDirectory() as tmpdir: + # Given a Python fixture with one invalid project metadata field. + root = Path(tmpdir) + plugin_dir = self._create_python_plugin(root, "python_demo") + suffix = "" + if include_entry_point: + suffix = ( + '\n[project.entry-points."cpex.plugins"]\n' + 'python_demo = "cpex_python_demo.plugin:PythonDemoPlugin"\n' + ) + (plugin_dir / "pyproject.toml").write_text(project_table + suffix) + + # When validation runs. + result = run_catalog("validate", str(root)) + + # Then malformed Python project metadata is rejected. + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected_error, result.stderr.lower()) + + def test_python_validator_rejects_manifest_version_mismatch(self) -> None: + # Given a Python plugin whose manifest is 0.1.0 but project version is 0.2.0. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + plugin_dir = self._create_python_plugin(root, "python_demo", version="0.2.0") + manifest = plugin_dir / "cpex_python_demo" / "plugin-manifest.yaml" + manifest.write_text(manifest.read_text().replace('version: "0.2.0"', 'version: "0.1.0"')) + + # When validation runs. + result = run_catalog("validate", str(root)) + + # Then the cross-file version mismatch is rejected. + self.assertNotEqual(result.returncode, 0) + self.assertIn("version mismatch", result.stderr.lower()) + + def test_python_validator_rejects_noncanonical_manifest_kind(self) -> None: + # Given a Python plugin manifest using module:object instead of module.object. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + plugin_dir = self._create_python_plugin(root, "python_demo") + manifest = plugin_dir / "cpex_python_demo" / "plugin-manifest.yaml" + manifest.write_text( + manifest.read_text().replace( + "cpex_python_demo.plugin.PythonDemoPlugin", + "cpex_python_demo.plugin:PythonDemoPlugin", + ) + ) + + # When validation runs. + result = run_catalog("validate", str(root)) + + # Then the established canonical kind rule is preserved. + self.assertNotEqual(result.returncode, 0) + self.assertIn("module.object", result.stderr.lower()) + + def test_python_validator_requires_root_uv_workspace_membership(self) -> None: + # Given a Python plugin omitted from root [tool.uv.workspace].members. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + self._create_python_plugin(root, "python_demo", add_workspace_member=False) + + # When validation runs. + result = run_catalog("validate", str(root)) + + # Then the missing uv workspace member is rejected. + self.assertNotEqual(result.returncode, 0) + self.assertIn("uv workspace", result.stderr.lower()) + + def test_mixed_change_routing_handles_both_plugin_roots_and_safe_test_fallback(self) -> None: + cases = { + "plugins/python/python_demo/README.md": ["python_demo"], + "plugins/tests/python_demo/test_plugin.py": ["python_demo"], + "plugins/tests/unknown_plugin/test_plugin.py": ["python_demo", "rust_demo"], + } + for changed_path, expected in cases.items(): + with self.subTest( + changed_path=changed_path + ), tempfile.TemporaryDirectory() as tmpdir: + # Given a committed mixed-language plugin repository. + root = Path(tmpdir) + rust_path = "plugins/rust/python-package/rust_demo" + (root / "Cargo.toml").write_text( + f'[workspace]\nmembers = ["{rust_path}"]\n' + '[workspace.package]\nrepository = "https://github.com/IBM/cpex-plugins"\n' + ) + self._create_plugin(root, "rust_demo") + self._create_python_plugin(root, "python_demo") + subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "--no-verify", "-m", "seed layout"], + cwd=root, + check=True, + capture_output=True, + ) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + changed_file = root / changed_path + changed_file.parent.mkdir(parents=True, exist_ok=True) + changed_file.write_text("# changed\n") + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "--no-verify", "-m", "change route"], + cwd=root, + check=True, + capture_output=True, + ) + + # When diff selection runs. + result = run_catalog("ci-selection", str(root), "diff", base, "HEAD") + + # Then direct paths are scoped and unknown integration slugs select all. + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["plugins"], expected) + + def test_mixed_framework_bridge_change_emits_only_rust_mutation_packages(self) -> None: + # Given a mixed repository where the Rust plugin depends on framework_bridge. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + rust_path = "plugins/rust/python-package/rust_demo" + (root / "Cargo.toml").write_text( + f'[workspace]\nmembers = ["{rust_path}"]\n' + '[workspace.package]\nrepository = "https://github.com/IBM/cpex-plugins"\n' + ) + rust_plugin = self._create_plugin(root, "rust_demo") + (rust_plugin / "Cargo.toml").write_text( + '[package]\nname = "rust_demo"\nversion = "0.0.1"\n' + 'repository = "https://github.com/IBM/cpex-plugins"\n\n' + '[dependencies]\ncpex_framework_bridge = { workspace = true }\n' + ) + self._create_python_plugin(root, "python_demo") + bridge = root / "crates" / "framework_bridge" / "src" / "lib.rs" + bridge.parent.mkdir(parents=True) + bridge.write_text("// seed\n") + subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "--no-verify", "-m", "seed layout"], + cwd=root, + check=True, + capture_output=True, + ) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + bridge.write_text("// changed\n") + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "--no-verify", "-m", "bridge change"], + cwd=root, + check=True, + capture_output=True, + ) + + # When mutation selection processes the shared Rust crate change. + result = run_catalog("ci-selection", str(root), "diff", base, "HEAD") + + # Then Python records never reach Cargo dependency or mutation payloads. + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["cargo_packages"], ["rust_demo"]) + self.assertEqual(payload["mutation_jobs"][0]["test_packages"], ["rust_demo"]) + + def test_python_version_bump_emits_python_release_validation_tag(self) -> None: + # Given a committed Python plugin bumped from 0.1.0 to 0.2.0. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + plugin = self._create_python_plugin(root, "python_demo", version="0.1.0") + subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "--no-verify", "-m", "seed layout"], + cwd=root, + check=True, + capture_output=True, + ) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + pyproject = plugin / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace('version = "0.1.0"', 'version = "0.2.0"')) + manifest = plugin / "cpex_python_demo" / "plugin-manifest.yaml" + manifest.write_text(manifest.read_text().replace('version: "0.1.0"', 'version: "0.2.0"')) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "--no-verify", "-m", "bump version"], + cwd=root, + check=True, + capture_output=True, + ) + + # When diff selection computes release validation tags. + result = run_catalog("ci-selection", str(root), "diff", base, "HEAD") + + # Then the Python pyproject version drives both aggregate and split tags. + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["release_validation_tags"], ["python-demo-v0.2.0"]) + self.assertEqual(payload["python_release_validation_tags"], ["python-demo-v0.2.0"]) + self.assertTrue(payload["has_python_release_validation_tags"]) + + def test_python_release_info_reports_language_and_path(self) -> None: + # Given a valid Python plugin. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + self._create_python_plugin(root, "python_demo") + + # When release info resolves its canonical tag. + result = run_catalog("release-info", str(root), "python-demo-v0.2.0") + + # Then release metadata identifies the Python catalog source. + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["language"], "python") + self.assertEqual(payload["path"], "plugins/python/python_demo") + + def test_ci_selection_reports_language_splits_and_counts(self) -> None: + # Given the repository and a mixed fixture repository. + result = run_catalog("ci-selection", str(REPO_ROOT), "all", "", "") + self.assertEqual(result.returncode, 0, result.stderr) + repository_payload = json.loads(result.stdout) + + # Then all-mode characterizes the required 7 Rust / 1 Python split. + self.assertEqual(repository_payload["rust_plugin_count"], 7) + self.assertEqual(repository_payload["python_plugin_count"], 1) + self.assertTrue(repository_payload["has_rust_plugins"]) + self.assertTrue(repository_payload["has_python_plugins"]) + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + rust_path = "plugins/rust/python-package/rust_demo" + (root / "Cargo.toml").write_text( + f'[workspace]\nmembers = ["{rust_path}"]\n' + '[workspace.package]\nrepository = "https://github.com/IBM/cpex-plugins"\n' + ) + self._create_plugin(root, "rust_demo") + self._create_python_plugin(root, "python_demo") + + # When mixed all-mode selection runs. + result = run_catalog("ci-selection", str(root), "all", "", "") + + # Then aggregate compatibility fields and language splits agree. + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["plugins"], ["python_demo", "rust_demo"]) + self.assertEqual(payload["rust_plugins"], ["rust_demo"]) + self.assertEqual(payload["python_plugins"], ["python_demo"]) + self.assertEqual(payload["rust_plugin_count"], 1) + self.assertEqual(payload["python_plugin_count"], 1) + self.assertEqual(payload["cargo_packages"], ["rust_demo"]) + self.assertEqual(payload["mutation_cargo_packages"], ["rust_demo"]) + self.assertEqual(payload["mutation_jobs"][0]["cargo_package"], "rust_demo") + + def test_ci_selection_validator_reemits_every_split_field(self) -> None: + # Given a complete valid payload containing aggregate and split fields. + payload = { + "plugins": ["python_demo", "rust_demo"], + "rust_plugins": ["rust_demo"], + "python_plugins": ["python_demo"], + "has_plugins": True, + "has_rust_plugins": True, + "has_python_plugins": True, + "plugin_count": 2, + "rust_plugin_count": 1, + "python_plugin_count": 1, + "cargo_packages": ["rust_demo"], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], + "release_validation_tags": ["python-demo-v0.2.0"], + "rust_release_validation_tags": [], + "python_release_validation_tags": ["python-demo-v0.2.0"], + "has_release_validation_tags": True, + "has_rust_release_validation_tags": False, + "has_python_release_validation_tags": True, + } + + # When the real validator parses and normalizes it. + result = subprocess.run( + ["python3", str(CI_SELECTION_VALIDATOR)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=False, + ) + + # Then every field survives strict validation unchanged. + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), payload) + + def test_ci_selection_validator_rejects_missing_malformed_and_mismatched_split_fields(self) -> None: + valid_payload = { + "plugins": ["python_demo"], + "rust_plugins": [], + "python_plugins": ["python_demo"], + "has_plugins": True, + "has_rust_plugins": False, + "has_python_plugins": True, + "plugin_count": 1, + "rust_plugin_count": 0, + "python_plugin_count": 1, + "cargo_packages": [], + "mutation_cargo_packages": [], + "has_mutation_cargo_packages": False, + "mutation_jobs": [], + "release_validation_tags": [], + "rust_release_validation_tags": [], + "python_release_validation_tags": [], + "has_release_validation_tags": False, + "has_rust_release_validation_tags": False, + "has_python_release_validation_tags": False, + } + malformed_payloads = { + "missing": {key: value for key, value in valid_payload.items() if key != "python_plugins"}, + "wrong type": {**valid_payload, "has_python_plugins": "true"}, + "malformed slug": {**valid_payload, "python_plugins": ["python-demo"]}, + "count mismatch": {**valid_payload, "python_plugin_count": 2}, + } + for case, payload in malformed_payloads.items(): + with self.subTest(case=case): + # When malformed input crosses the validator boundary. + result = subprocess.run( + ["python3", str(CI_SELECTION_VALIDATOR)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=False, + ) + + # Then it is rejected rather than normalized into misleading success. + self.assertNotEqual(result.returncode, 0) + + def test_coverage_default_expected_plugins_remains_rust_only(self) -> None: + # Given mixed discovery and a Rust-only coverage report. + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + rust_path = "plugins/rust/python-package/rust_demo" + (root / "Cargo.toml").write_text( + f'[workspace]\nmembers = ["{rust_path}"]\n' + '[workspace.package]\nrepository = "https://github.com/IBM/cpex-plugins"\n' + ) + self._create_plugin(root, "rust_demo") + self._create_python_plugin(root, "python_demo") + report = root / "coverage.xml" + report.write_text( + '' + '' + '' + '' + ) + + # When coverage-check uses its discovered default expectation. + result = run_catalog("coverage-check", str(root), str(report), "100") + + # Then a pure-Python plugin is not required in Rust coverage XML. + self.assertEqual(result.returncode, 0, result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/tools/plugin_catalog.py b/tools/plugin_catalog.py index fe3ce3b..504426e 100644 --- a/tools/plugin_catalog.py +++ b/tools/plugin_catalog.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Plugin discovery and validation for managed Rust Python-package plugins.""" +"""Plugin discovery and validation for managed CPEX plugins.""" from __future__ import annotations @@ -16,6 +16,7 @@ MANAGED_ROOT = Path("plugins/rust/python-package") +PYTHON_MANAGED_ROOT = Path("plugins/python") REPOSITORY_URL = "https://github.com/IBM/cpex-plugins" SHARED_PATH_PREFIXES = ( "Makefile", @@ -131,8 +132,9 @@ class CatalogError(Exception): class PluginRecord: slug: str path: str + language: str package_name: str - cargo_package_name: str + cargo_package_name: str | None module_name: str kind: str version: str @@ -275,6 +277,21 @@ def discover_plugins(root: Path) -> list[PluginRecord]: plugins.append( validate_plugin_dir(root, plugin_dir, workspace_members, workspace_package) ) + rust_paths_by_slug = {plugin.slug: plugin.path for plugin in plugins} + python_root = root / PYTHON_MANAGED_ROOT + if python_root.exists(): + for plugin_dir in sorted( + path for path in python_root.iterdir() if path.is_dir() + ): + if plugin_dir.name in rust_paths_by_slug: + python_path = plugin_dir.relative_to(root).as_posix() + raise CatalogError( + f"Duplicate plugin slug {plugin_dir.name!r} across managed roots: " + f"{rust_paths_by_slug[plugin_dir.name]} and {python_path}" + ) + plugins.append( + validate_plugin_dir(root, plugin_dir, workspace_members, workspace_package) + ) _validate_workspace_members(workspace_members, plugins) _validate_workspace_dependency_ownership(root, plugins) return plugins @@ -283,12 +300,21 @@ def discover_plugins(root: Path) -> list[PluginRecord]: def _validate_workspace_members( workspace_members: set[str], plugins: list[PluginRecord] ) -> None: - expected_members = {plugin.path for plugin in plugins} + expected_members = {plugin.path for plugin in plugins if plugin.language == "rust"} if not expected_members.issubset(workspace_members): missing = sorted(expected_members - workspace_members) raise CatalogError( f"Workspace members must include all discovered managed plugins: missing {missing}" ) + python_members = sorted( + plugin.path + for plugin in plugins + if plugin.language == "python" and plugin.path in workspace_members + ) + if python_members: + raise CatalogError( + f"Pure-Python plugins must not be Cargo workspace members: {python_members}" + ) def _workspace_members(root: Path) -> set[str]: @@ -324,7 +350,9 @@ def _workspace_package_metadata(root: Path) -> dict: def _validate_workspace_dependency_ownership( root: Path, plugins: list[PluginRecord] ) -> None: - plugin_records = {plugin.slug: plugin for plugin in plugins} + plugin_records = { + plugin.slug: plugin for plugin in plugins if plugin.language == "rust" + } if set(plugin_records) != set(REQUIRED_PLUGIN_WORKSPACE_DEPENDENCIES): return @@ -375,85 +403,122 @@ def validate_plugin_dir( expected_module_name = _expected_module_name(slug) module_dir = plugin_dir / expected_module_name manifest_path = module_dir / "plugin-manifest.yaml" + relative_plugin_path = plugin_dir.relative_to(root).as_posix() + language = ( + "python" + if relative_plugin_path.startswith(f"{PYTHON_MANAGED_ROOT.as_posix()}/") + else "rust" + ) - required_paths = ( + required_paths = [ plugin_dir / "pyproject.toml", - plugin_dir / "Cargo.toml", plugin_dir / "Makefile", plugin_dir / "README.md", module_dir / "__init__.py", manifest_path, - ) + ] + if language == "rust": + required_paths.append(plugin_dir / "Cargo.toml") for required in required_paths: if not required.exists(): raise CatalogError(f"{plugin_dir}: missing required path {required.relative_to(root)}") pyproject = _parse_pyproject(plugin_dir / "pyproject.toml") - cargo = _parse_cargo(plugin_dir / "Cargo.toml") project = pyproject.get("project", {}) if not isinstance(project, dict): raise CatalogError(f"{plugin_dir}: pyproject.toml [project] must be a table") - tool = pyproject.get("tool", {}) - maturin = tool.get("maturin", {}) if isinstance(tool, dict) else {} - if not isinstance(maturin, dict): - raise CatalogError(f"{plugin_dir}: pyproject.toml tool.maturin must be a table") - package = cargo.get("package", {}) - if not isinstance(package, dict): - raise CatalogError(f"{plugin_dir}: Cargo.toml [package] must be a table") - relative_plugin_path = plugin_dir.relative_to(root).as_posix() - - if relative_plugin_path not in workspace_members: - raise CatalogError( - f"{plugin_dir}: plugin is missing from the top-level Cargo workspace" - ) - package_name = project.get("name") if package_name != expected_package_name: raise CatalogError( f"{plugin_dir}: package name must be {expected_package_name}, got {package_name}" ) - dynamic = project.get("dynamic", []) - if not isinstance(dynamic, list) or any(not isinstance(item, str) for item in dynamic): - raise CatalogError( - f"{plugin_dir}: pyproject.toml must declare dynamic version sourced from Cargo.toml" - ) - if "version" not in dynamic or "version" in project: - raise CatalogError( - f"{plugin_dir}: pyproject.toml must declare dynamic version sourced from Cargo.toml" - ) - - module_name = maturin.get("module-name") - expected_maturin_module_name = _expected_maturin_module_name(slug) - if module_name != expected_maturin_module_name: - raise CatalogError( - f"{plugin_dir}: tool.maturin.module-name must be {expected_maturin_module_name}, got {module_name}" - ) - - python_source = maturin.get("python-source") - if python_source != ".": - raise CatalogError( - f"{plugin_dir}: tool.maturin.python-source must be '.', got {python_source}" - ) - - repository = package.get("repository") - if isinstance(repository, dict) and repository.get("workspace") is True: - repository = workspace_package.get("repository") - if repository != REPOSITORY_URL: - raise CatalogError( - f"{plugin_dir}: repository metadata must point to {REPOSITORY_URL}, got {repository}" - ) - - version = package.get("version") - if not isinstance(version, str) or not version: - raise CatalogError(f"{plugin_dir}: Cargo.toml must define a non-empty package.version") - - cargo_package_name = package.get("name") - if cargo_package_name != slug: - raise CatalogError( - f"{plugin_dir}: Cargo.toml [package].name must be {slug}, got {cargo_package_name}" + if language == "python": + dynamic = project.get("dynamic", []) + if not isinstance(dynamic, list) or any( + not isinstance(item, str) for item in dynamic + ): + raise CatalogError(f"{plugin_dir}: pyproject.toml project.dynamic must be a string list") + if "version" in dynamic: + raise CatalogError( + f"{plugin_dir}: pure-Python plugin version must be static, not dynamic" + ) + version = project.get("version") + if not isinstance(version, str) or not version: + raise CatalogError( + f"{plugin_dir}: pyproject.toml must define a non-empty static project.version" + ) + root_pyproject = _parse_pyproject(root / "pyproject.toml") + root_tool = root_pyproject.get("tool", {}) + root_uv = root_tool.get("uv", {}) if isinstance(root_tool, dict) else {} + root_workspace = root_uv.get("workspace", {}) if isinstance(root_uv, dict) else {} + uv_members = ( + root_workspace.get("members") if isinstance(root_workspace, dict) else None ) + if not isinstance(uv_members, list) or any( + not isinstance(member, str) for member in uv_members + ): + raise CatalogError( + "Root pyproject.toml must define [tool.uv.workspace].members as a string list" + ) + if relative_plugin_path not in uv_members: + raise CatalogError( + f"{plugin_dir}: plugin is missing from the root uv workspace" + ) + cargo_package_name = None + else: + cargo = _parse_cargo(plugin_dir / "Cargo.toml") + tool = pyproject.get("tool", {}) + maturin = tool.get("maturin", {}) if isinstance(tool, dict) else {} + if not isinstance(maturin, dict): + raise CatalogError(f"{plugin_dir}: pyproject.toml tool.maturin must be a table") + package = cargo.get("package", {}) + if not isinstance(package, dict): + raise CatalogError(f"{plugin_dir}: Cargo.toml [package] must be a table") + if relative_plugin_path not in workspace_members: + raise CatalogError( + f"{plugin_dir}: plugin is missing from the top-level Cargo workspace" + ) + dynamic = project.get("dynamic", []) + if not isinstance(dynamic, list) or any( + not isinstance(item, str) for item in dynamic + ): + raise CatalogError( + f"{plugin_dir}: pyproject.toml must declare dynamic version sourced from Cargo.toml" + ) + if "version" not in dynamic or "version" in project: + raise CatalogError( + f"{plugin_dir}: pyproject.toml must declare dynamic version sourced from Cargo.toml" + ) + module_name = maturin.get("module-name") + expected_maturin_module_name = _expected_maturin_module_name(slug) + if module_name != expected_maturin_module_name: + raise CatalogError( + f"{plugin_dir}: tool.maturin.module-name must be {expected_maturin_module_name}, got {module_name}" + ) + python_source = maturin.get("python-source") + if python_source != ".": + raise CatalogError( + f"{plugin_dir}: tool.maturin.python-source must be '.', got {python_source}" + ) + repository = package.get("repository") + if isinstance(repository, dict) and repository.get("workspace") is True: + repository = workspace_package.get("repository") + if repository != REPOSITORY_URL: + raise CatalogError( + f"{plugin_dir}: repository metadata must point to {REPOSITORY_URL}, got {repository}" + ) + version = package.get("version") + if not isinstance(version, str) or not version: + raise CatalogError( + f"{plugin_dir}: Cargo.toml must define a non-empty package.version" + ) + cargo_package_name = package.get("name") + if cargo_package_name != slug: + raise CatalogError( + f"{plugin_dir}: Cargo.toml [package].name must be {slug}, got {cargo_package_name}" + ) manifest_version = _manifest_version(manifest_path) if manifest_version != version: @@ -475,6 +540,7 @@ def validate_plugin_dir( return PluginRecord( slug=slug, path=relative_plugin_path, + language=language, package_name=expected_package_name, cargo_package_name=cargo_package_name, module_name=expected_module_name, @@ -522,6 +588,18 @@ def _cargo_version_from_text(text: str) -> str | None: return version if isinstance(version, str) else None +def _project_version_from_text(text: str) -> str | None: + try: + payload = tomllib.loads(text) + except tomllib.TOMLDecodeError: + return None + project = payload.get("project", {}) + if not isinstance(project, dict): + return None + version = project.get("version") + return version if isinstance(version, str) else None + + def changed_plugins(root: Path, base: str, head: str) -> list[str]: plugins = discover_plugins(root) return _changed_plugins_for_records(root, plugins, _git_changed_paths(root, base, head)) @@ -542,13 +620,19 @@ def _changed_plugins_for_records( return sorted(plugin_lookup) changed: set[str] = set() - managed_prefix = f"{MANAGED_ROOT.as_posix()}/" + managed_prefixes = ( + f"{MANAGED_ROOT.as_posix()}/", + f"{PYTHON_MANAGED_ROOT.as_posix()}/", + ) integration_prefix = "plugins/tests/" has_lockfile_change = "Cargo.lock" in changed_path_set for path in normalized_changed_paths: if path == "Cargo.lock": continue - if not path.startswith(managed_prefix): + matching_prefix = next( + (prefix for prefix in managed_prefixes if path.startswith(prefix)), None + ) + if matching_prefix is None: if not path.startswith(integration_prefix): continue relative = path[len(integration_prefix):] @@ -556,7 +640,7 @@ def _changed_plugins_for_records( if slug not in plugin_lookup: return sorted(plugin_lookup) else: - relative = path[len(managed_prefix):] + relative = path[len(matching_prefix):] slug = relative.split("/", maxsplit=1)[0] if slug in plugin_lookup: changed.add(slug) @@ -566,6 +650,8 @@ def _changed_plugins_for_records( def _plugin_depends_on_crate(root: Path, record: PluginRecord, crate_name: str) -> bool: + if record.language != "rust": + return False manifest_path = root / record.path / "Cargo.toml" with manifest_path.open("rb") as handle: manifest = tomllib.load(handle) @@ -575,7 +661,9 @@ def _plugin_depends_on_crate(root: Path, record: PluginRecord, crate_name: str) def _mutation_jobs_for_records( root: Path, plugins: list[PluginRecord], changed_paths: list[str] ) -> list[dict[str, object]]: - plugin_lookup = {record.slug: record for record in plugins} + plugin_lookup = { + record.slug: record for record in plugins if record.language == "rust" + } jobs: dict[str, dict[str, object]] = {} managed_prefix = f"{MANAGED_ROOT.as_posix()}/" @@ -592,7 +680,11 @@ def add_job(cargo_package: str, *, in_diff: bool, test_packages: list[str] | Non if path.startswith("crates/framework_bridge/"): test_packages: list[str] = [] for record in plugins: - if _plugin_depends_on_crate(root, record, "cpex_framework_bridge"): + if ( + record.language == "rust" + and record.cargo_package_name is not None + and _plugin_depends_on_crate(root, record, "cpex_framework_bridge") + ): test_packages.append(record.cargo_package_name) add_job("cpex_framework_bridge", in_diff=True, test_packages=sorted(test_packages)) continue @@ -601,7 +693,9 @@ def add_job(cargo_package: str, *, in_diff: bool, test_packages: list[str] | Non relative = path[len(managed_prefix):] slug = relative.split("/", maxsplit=1)[0] if slug in plugin_lookup: - add_job(plugin_lookup[slug].cargo_package_name, in_diff=True) + cargo_package_name = plugin_lookup[slug].cargo_package_name + if cargo_package_name is not None: + add_job(cargo_package_name, in_diff=True) return [jobs[key] for key in sorted(jobs)] @@ -611,14 +705,22 @@ def _release_validation_tags_for_records( ) -> list[str]: tags: list[str] = [] for plugin in plugins: - cargo_path = f"{plugin.path}/Cargo.toml" - if cargo_path not in changed_paths: + version_path = ( + f"{plugin.path}/Cargo.toml" + if plugin.language == "rust" + else f"{plugin.path}/pyproject.toml" + ) + if version_path not in changed_paths: continue - old_text = _git_file_text(root, base, cargo_path) + old_text = _git_file_text(root, base, version_path) if old_text is None: tags.append(f"{plugin.slug.replace('_', '-')}-v{plugin.version}") continue - old_version = _cargo_version_from_text(old_text) + old_version = ( + _cargo_version_from_text(old_text) + if plugin.language == "rust" + else _project_version_from_text(old_text) + ) if old_version is not None and old_version != plugin.version: tags.append(f"{plugin.slug.replace('_', '-')}-v{plugin.version}") return sorted(tags) @@ -636,6 +738,8 @@ def ci_selection(root: Path, mode: str, base: str | None = None, head: str | Non "test_packages": [], } for slug in selected + if plugin_lookup[slug].language == "rust" + and plugin_lookup[slug].cargo_package_name is not None ] release_validation_tags: list[str] = [] else: @@ -647,18 +751,48 @@ def ci_selection(root: Path, mode: str, base: str | None = None, head: str | Non release_validation_tags = _release_validation_tags_for_records( root, plugins, changed_paths, base ) - cargo_packages = [plugin_lookup[slug].cargo_package_name for slug in selected] + rust_plugins = [slug for slug in selected if plugin_lookup[slug].language == "rust"] + python_plugins = [ + slug for slug in selected if plugin_lookup[slug].language == "python" + ] + cargo_packages = [ + plugin_lookup[slug].cargo_package_name + for slug in rust_plugins + if plugin_lookup[slug].cargo_package_name is not None + ] mutation_cargo_packages = [str(job["cargo_package"]) for job in mutation_jobs] + rust_release_validation_tags = [ + tag + for tag in release_validation_tags + if plugin_lookup[tag.rsplit("-v", maxsplit=1)[0].replace("-", "_")].language + == "rust" + ] + python_release_validation_tags = [ + tag + for tag in release_validation_tags + if plugin_lookup[tag.rsplit("-v", maxsplit=1)[0].replace("-", "_")].language + == "python" + ] return { "plugins": selected, + "rust_plugins": rust_plugins, + "python_plugins": python_plugins, "has_plugins": bool(selected), + "has_rust_plugins": bool(rust_plugins), + "has_python_plugins": bool(python_plugins), "plugin_count": len(selected), + "rust_plugin_count": len(rust_plugins), + "python_plugin_count": len(python_plugins), "cargo_packages": cargo_packages, "mutation_cargo_packages": mutation_cargo_packages, "has_mutation_cargo_packages": bool(mutation_cargo_packages), "mutation_jobs": mutation_jobs, "release_validation_tags": release_validation_tags, + "rust_release_validation_tags": rust_release_validation_tags, + "python_release_validation_tags": python_release_validation_tags, "has_release_validation_tags": bool(release_validation_tags), + "has_rust_release_validation_tags": bool(rust_release_validation_tags), + "has_python_release_validation_tags": bool(python_release_validation_tags), } @@ -678,7 +812,11 @@ def coverage_check( except ET.ParseError as exc: raise CatalogError(f"Invalid coverage XML in {report_path}: {exc}") from exc - known_plugins = {plugin.slug for plugin in discover_plugins(root)} + known_plugins = { + plugin.slug + for plugin in discover_plugins(root) + if plugin.language == "rust" + } expected_plugin_set = set(expected_plugins) if expected_plugins is not None else known_plugins unknown_expected = sorted(expected_plugin_set - known_plugins) if unknown_expected: @@ -771,7 +909,7 @@ def release_info(root: Path, tag: str) -> PluginRecord: ) if plugin.version != version: raise CatalogError( - f"Release tag version {version} does not match Cargo/plugin manifest version {plugin.version} for {slug}" + f"Release tag version {version} does not match catalog plugin version {plugin.version} for {slug}" ) return plugin @@ -877,6 +1015,7 @@ def build_parser() -> argparse.ArgumentParser: choices=( "slug", "path", + "language", "package_name", "cargo_package_name", "module_name", @@ -901,14 +1040,24 @@ def build_parser() -> argparse.ArgumentParser: "field", choices=( "plugins", + "rust_plugins", + "python_plugins", "has_plugins", + "has_rust_plugins", + "has_python_plugins", "plugin_count", + "rust_plugin_count", + "python_plugin_count", "cargo_packages", "mutation_cargo_packages", "has_mutation_cargo_packages", "mutation_jobs", "release_validation_tags", + "rust_release_validation_tags", + "python_release_validation_tags", "has_release_validation_tags", + "has_rust_release_validation_tags", + "has_python_release_validation_tags", ), ) diff --git a/tools/validate_ci_selection.py b/tools/validate_ci_selection.py index 7bf82c3..25532d0 100644 --- a/tools/validate_ci_selection.py +++ b/tools/validate_ci_selection.py @@ -40,39 +40,112 @@ def _assert_mutation_jobs(value: object) -> list[dict[str, object]]: return value +def _assert_string_list(value: object, field_name: str) -> list[str]: + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise AssertionError(f"{field_name} must be a string list") + return value + + +def _assert_bool(value: object, field_name: str, expected: bool) -> bool: + if not isinstance(value, bool): + raise AssertionError(f"{field_name} must be bool") + if value is not expected: + raise AssertionError(f"{field_name} must match its list") + return value + + +def _assert_count(value: object, field_name: str, items: list[str]) -> int: + if type(value) is not int or value != len(items): + raise AssertionError(f"{field_name} must equal len of its plugin list") + return value + + def main() -> int: payload = json.load(sys.stdin) + if not isinstance(payload, dict): + raise AssertionError("CI selection payload must be an object") plugins = _assert_slug_list(payload.get("plugins"), "plugins") + rust_plugins = _assert_slug_list(payload.get("rust_plugins"), "rust_plugins") + python_plugins = _assert_slug_list(payload.get("python_plugins"), "python_plugins") cargo_packages = _assert_slug_list(payload.get("cargo_packages"), "cargo_packages") mutation_cargo_packages = _assert_slug_list( payload.get("mutation_cargo_packages"), "mutation_cargo_packages" ) mutation_jobs = _assert_mutation_jobs(payload.get("mutation_jobs")) - release_validation_tags = payload.get("release_validation_tags") - has_plugins = payload.get("has_plugins") - plugin_count = payload.get("plugin_count") - - if not isinstance(release_validation_tags, list) or any( - not isinstance(item, str) for item in release_validation_tags + release_validation_tags = _assert_string_list( + payload.get("release_validation_tags"), "release_validation_tags" + ) + rust_release_validation_tags = _assert_string_list( + payload.get("rust_release_validation_tags"), "rust_release_validation_tags" + ) + python_release_validation_tags = _assert_string_list( + payload.get("python_release_validation_tags"), "python_release_validation_tags" + ) + has_plugins = _assert_bool(payload.get("has_plugins"), "has_plugins", bool(plugins)) + has_rust_plugins = _assert_bool( + payload.get("has_rust_plugins"), "has_rust_plugins", bool(rust_plugins) + ) + has_python_plugins = _assert_bool( + payload.get("has_python_plugins"), "has_python_plugins", bool(python_plugins) + ) + plugin_count = _assert_count(payload.get("plugin_count"), "plugin_count", plugins) + rust_plugin_count = _assert_count( + payload.get("rust_plugin_count"), "rust_plugin_count", rust_plugins + ) + python_plugin_count = _assert_count( + payload.get("python_plugin_count"), "python_plugin_count", python_plugins + ) + has_mutation_cargo_packages = _assert_bool( + payload.get("has_mutation_cargo_packages"), + "has_mutation_cargo_packages", + bool(mutation_cargo_packages), + ) + has_release_validation_tags = _assert_bool( + payload.get("has_release_validation_tags"), + "has_release_validation_tags", + bool(release_validation_tags), + ) + has_rust_release_validation_tags = _assert_bool( + payload.get("has_rust_release_validation_tags"), + "has_rust_release_validation_tags", + bool(rust_release_validation_tags), + ) + has_python_release_validation_tags = _assert_bool( + payload.get("has_python_release_validation_tags"), + "has_python_release_validation_tags", + bool(python_release_validation_tags), + ) + if plugins != sorted(rust_plugins + python_plugins): + raise AssertionError("plugins must equal the combined language plugin lists") + if release_validation_tags != sorted( + rust_release_validation_tags + python_release_validation_tags ): - raise AssertionError("release_validation_tags must be a string list") - if not isinstance(has_plugins, bool): - raise AssertionError("has_plugins must be bool") - if not isinstance(plugin_count, int) or plugin_count != len(plugins): - raise AssertionError("plugin_count must equal len(plugins)") + raise AssertionError( + "release_validation_tags must equal the combined language tag lists" + ) print( json.dumps( { "plugins": plugins, + "rust_plugins": rust_plugins, + "python_plugins": python_plugins, "has_plugins": has_plugins, + "has_rust_plugins": has_rust_plugins, + "has_python_plugins": has_python_plugins, "plugin_count": plugin_count, + "rust_plugin_count": rust_plugin_count, + "python_plugin_count": python_plugin_count, "cargo_packages": cargo_packages, "mutation_cargo_packages": mutation_cargo_packages, "mutation_jobs": mutation_jobs, - "has_mutation_cargo_packages": bool(mutation_cargo_packages), + "has_mutation_cargo_packages": has_mutation_cargo_packages, "release_validation_tags": release_validation_tags, - "has_release_validation_tags": bool(release_validation_tags), + "rust_release_validation_tags": rust_release_validation_tags, + "python_release_validation_tags": python_release_validation_tags, + "has_release_validation_tags": has_release_validation_tags, + "has_rust_release_validation_tags": has_rust_release_validation_tags, + "has_python_release_validation_tags": has_python_release_validation_tags, } ) ) diff --git a/uv.lock b/uv.lock index 56bacd9..39abbd9 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", "python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version < '3.14' and sys_platform != 'win32'", ] @@ -15,6 +17,7 @@ exclude-newer-span = "P10D" [manifest] members = [ "cpex-encoded-exfil-detection", + "cpex-ica-metering-exporter", "cpex-pii-filter", "cpex-plugins", "cpex-rate-limiter", @@ -75,6 +78,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -365,6 +432,39 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.3.0" }, ] +[[package]] +name = "cpex-ica-metering-exporter" +version = "0.1.0" +source = { editable = "plugins/python/ica_metering_exporter" } +dependencies = [ + { name = "cpex" }, + { name = "httpx" }, + { name = "pyjwt" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "cpex", specifier = ">=0.1.3,<0.2" }, + { name = "httpx", specifier = ">=0.27,<1" }, + { name = "pyjwt", specifier = ">=2.8,<3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "ruff" }, +] + [[package]] name = "cpex-pii-filter" source = { editable = "plugins/rust/python-package/pii_filter" } @@ -802,6 +902,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -943,6 +1159,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -1020,6 +1297,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1575,6 +1861,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + [[package]] name = "runs" version = "1.3.0"