diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8d14cf0..edd0e77 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @socketdev/eng +* @SocketDev/customer-engineering \ No newline at end of file diff --git a/.github/actions/setup-hatch/action.yml b/.github/actions/setup-hatch/action.yml new file mode 100644 index 0000000..0da5160 --- /dev/null +++ b/.github/actions/setup-hatch/action.yml @@ -0,0 +1,13 @@ +name: "Set up Hatch build tooling" +description: >- + Install the pinned hatch / hatchling / virtualenv toolchain used to build + and publish the package. Assumes Python is already set up by the caller. + +runs: + using: "composite" + steps: + - shell: bash + run: | + python -m pip install --upgrade pip + pip install "virtualenv<20.36" + pip install hatchling==1.27.0 hatch==1.14.0 diff --git a/.github/actions/setup-sfw/action.yml b/.github/actions/setup-sfw/action.yml new file mode 100644 index 0000000..ffe006d --- /dev/null +++ b/.github/actions/setup-sfw/action.yml @@ -0,0 +1,41 @@ +name: "Set up Socket Firewall" +description: >- + Set up the requested Python/uv toolchain and install Socket Firewall so + subsequent steps can run package-manager commands wrapped with `sfw`. + Defaults to free/anonymous mode (no API token -- safe on untrusted / + Dependabot / fork PRs). Pass mode: firewall-enterprise + socket-token for + full org-policy enforcement on trusted maintainer PRs. + +inputs: + python: + description: "Set up Python 3.12" + default: "false" + uv: + description: "Install uv (implies Python)" + default: "false" + mode: + description: "socketdev/action mode: firewall-free or firewall-enterprise" + default: "firewall-free" + socket-token: + description: "Socket API token (only used/required for firewall-enterprise)" + default: "" + +runs: + using: "composite" + steps: + - if: ${{ inputs.python == 'true' || inputs.uv == 'true' }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + # Official Socket setup action. Wires up sfw routing correctly. + # socket-token is ignored in firewall-free mode and empty when absent. + - uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 + with: + mode: ${{ inputs.mode }} + socket-token: ${{ inputs.socket-token }} + + - if: ${{ inputs.uv == 'true' }} + name: Install uv + shell: bash + run: python -m pip install --upgrade pip uv diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c02d0dd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,63 @@ +# Dependabot configuration for socket-sdk-python. +# +# Design notes: +# - Python deps are grouped into a weekly PR (minor/patch), with a +# separate group for majors so breaking bumps stay reviewable. +# - GitHub Actions are grouped similarly into one weekly PR, and Dependabot +# scans both the workflows and the local composite actions. +# - 7-day cooldown enforced across all ecosystems. +# - This repo ships no Dockerfile, so there is no docker ecosystem entry. + +version: 2 +updates: + + # Python deps (uv-tracked via uv.lock) + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 2 + groups: + python-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + python-major: + patterns: + - "*" + update-types: + - "major" + labels: + - "dependencies" + - "python:uv" + commit-message: + prefix: "chore" + include: "scope" + cooldown: + default-days: 7 + + # GitHub Actions used in workflows and local composite actions. + - package-ecosystem: "github-actions" + directories: + - "/" + - "/.github/actions/*" + schedule: + interval: "weekly" + open-pull-requests-limit: 2 + groups: + github-actions-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" + include: "scope" + cooldown: + default-days: 7 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..fd1d8fc --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,330 @@ +name: dependency-review + +# Supply-chain guardrails for dependency-update PRs -- for BOTH Dependabot +# and maintainers. `inspect` classifies the PR, then exactly one Socket +# Firewall (sfw) install smoke job runs when Python deps change: +# +# - python-sfw-smoke-enterprise -- trusted authors: any in-repo (non-fork) +# PR other than Dependabot's (i.e. someone with write access). Runs the +# authenticated enterprise edition for full org-policy enforcement. The +# SOCKET_SFW_API_TOKEN is scoped to the `socket-firewall` environment, so +# it is the ONLY job that can read the token. +# - python-sfw-smoke-free -- everyone else (Dependabot + all fork PRs from +# external contributors). Anonymous free edition, no token. This job never +# references the secret. +# +# Splitting the jobs (rather than picking a mode in one job) keeps the token +# out of scope on every untrusted run and satisfies zizmor's +# `secrets-outside-env` audit without suppressing it. The free path runs in +# the unprivileged `pull_request` context with no secret-leak surface. +# +# Pattern adapted from SocketDev/socket-python-cli. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + inspect: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + python_deps_changed: ${{ steps.diff.outputs.python_deps_changed }} + workflow_or_action_changed: ${{ steps.diff.outputs.workflow_or_action_changed }} + is_trusted: ${{ steps.trust.outputs.is_trusted }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Inspect changed files + id: diff + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + + { + echo "## Changed files" + echo '```' + printf '%s\n' "$CHANGED_FILES" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + has_file() { + local pattern="$1" + if printf '%s\n' "$CHANGED_FILES" | grep -Eq "$pattern"; then + echo "true" + else + echo "false" + fi + } + + { + echo "python_deps_changed=$(has_file '^(pyproject\.toml|uv\.lock)$')" + echo "workflow_or_action_changed=$(has_file '^\.github/workflows/|^\.github/actions/|^\.github/dependabot\.yml$')" + } >> "$GITHUB_OUTPUT" + + - name: Classify PR trust + id: trust + # Trusted == any in-repo (non-fork) PR that isn't Dependabot's. Only + # accounts with write access can push a branch to this repo, so a + # non-fork PR already implies a trusted author -- the same boundary + # GitHub uses to decide whether secrets are exposed at all. + # + # NB: author_association is deliberately NOT used to require strict org + # membership. It only reflects PUBLIC org membership, so private members + # (the common case) show up as CONTRIBUTOR and would be misclassified. + # Reliable strict-membership detection would need a read:org token or + # public membership. This step references NO secret regardless -- it + # only decides which smoke job runs. + env: + IS_DEPENDABOT: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} + IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }} + run: | + is_trusted=false + if [ "$IS_DEPENDABOT" != "true" ] && [ "$IS_FORK" != "true" ]; then + is_trusted=true + fi + + echo "is_trusted=$is_trusted" >> "$GITHUB_OUTPUT" + { + echo "## Socket Firewall edition: \`$([ "$is_trusted" = true ] && echo enterprise || echo free)\`" + echo "- author_association: \`$AUTHOR_ASSOC\`" + echo "- dependabot: \`$IS_DEPENDABOT\` | fork: \`$IS_FORK\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Summarize review expectations + env: + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + { + echo "## Dependency Review Checklist" + echo "- PR: $PR_URL" + echo "- Confirm upstream release notes before merge" + echo "- Do not treat a dependency PR as trusted solely because of the actor" + echo "- This workflow runs in pull_request context only; no publish secrets are exposed" + } >> "$GITHUB_STEP_SUMMARY" + + # Untrusted PRs (Dependabot, forks, outside collaborators, externals): + # anonymous free edition. Never references the token. + python-sfw-smoke-free: + needs: inspect + if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted != 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Prepare SFW artifact directory + run: | + mkdir -p sfw-artifacts + { + echo "mode=firewall-free" + echo "pr=${{ github.event.pull_request.number }}" + echo "sha=${{ github.event.pull_request.head.sha }}" + } > sfw-artifacts/context.txt + + - uses: ./.github/actions/setup-sfw + with: + uv: "true" + mode: firewall-free + + - name: Sync project through Socket Firewall (free) + # pipefail keeps sfw's exit code through the tee so a firewall block + # still fails the job; tee captures the report for the artifact upload. + env: + UV_PYTHON: "3.12" + UV_PYTHON_DOWNLOADS: never + run: | + set -o pipefail + sfw uv sync --locked --extra test --extra dev 2>&1 | tee sfw-artifacts/sfw-uv-sync.log + + - name: Import smoke test + run: | + set -o pipefail + uv run python -c " + import socketdev + from socketdev import socketdev as SocketDevClient + from socketdev.core.api import API + from socketdev.version import __version__ + print('import smoke OK', __version__) + " 2>&1 | tee sfw-artifacts/import-smoke.log + + - name: Collect SFW JSON report + # socketdev/action points sfw at SFW_JSON_REPORT_PATH (a $RUNNER_TEMP + # file) and reads it back in its post step to render the job summary, so + # COPY (don't move) the report into the bundle. sfw writes it even when + # it blocks an install -- always() keeps it on failures too. + if: always() + run: | + if [ -n "${SFW_JSON_REPORT_PATH:-}" ] && [ -f "$SFW_JSON_REPORT_PATH" ]; then + cp "$SFW_JSON_REPORT_PATH" "$GITHUB_WORKSPACE/sfw-artifacts/sfw-report.json" + echo "Collected SFW report -> sfw-artifacts/sfw-report.json" + else + echo "No SFW JSON report found at '${SFW_JSON_REPORT_PATH:-}'." \ + > "$GITHUB_WORKSPACE/sfw-artifacts/sfw-report-missing.txt" + fi + + - name: Upload SFW report artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socket-firewall-free-${{ github.event.pull_request.number }} + path: sfw-artifacts/ + if-no-files-found: warn + retention-days: 14 + + # Trusted SocketDev members: authenticated enterprise edition. The token is + # scoped to the `socket-firewall` environment, so only this job can read it. + python-sfw-smoke-enterprise: + needs: inspect + if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: socket-firewall + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Prepare SFW artifact directory + run: | + mkdir -p sfw-artifacts + { + echo "mode=firewall-enterprise" + echo "pr=${{ github.event.pull_request.number }}" + echo "sha=${{ github.event.pull_request.head.sha }}" + } > sfw-artifacts/context.txt + + - uses: ./.github/actions/setup-sfw + with: + uv: "true" + mode: firewall-enterprise + socket-token: ${{ secrets.SOCKET_SFW_API_TOKEN }} + + - name: Sync project through Socket Firewall (enterprise) + # See free job for the UV_PYTHON rationale: .python-version pins an + # exact patch that uv would otherwise fetch from GitHub through the + # firewall (blocked by its TLS interception); use the runner's Python. + # + # pipefail keeps sfw's exit code through the tee so a firewall block + # still fails the job; tee captures the report for the artifact upload. + env: + UV_PYTHON: "3.12" + UV_PYTHON_DOWNLOADS: never + run: | + set -o pipefail + sfw uv sync --locked --extra test --extra dev 2>&1 | tee sfw-artifacts/sfw-uv-sync.log + + - name: Import smoke test + run: | + set -o pipefail + uv run python -c " + import socketdev + from socketdev import socketdev as SocketDevClient + from socketdev.core.api import API + from socketdev.version import __version__ + print('import smoke OK', __version__) + " 2>&1 | tee sfw-artifacts/import-smoke.log + + - name: Collect SFW JSON report + # socketdev/action points sfw at SFW_JSON_REPORT_PATH (a $RUNNER_TEMP + # file) and reads it back in its post step to render the job summary, so + # COPY (don't move) the report into the bundle. sfw writes it even when + # it blocks an install -- always() keeps it on failures too. + if: always() + run: | + if [ -n "${SFW_JSON_REPORT_PATH:-}" ] && [ -f "$SFW_JSON_REPORT_PATH" ]; then + cp "$SFW_JSON_REPORT_PATH" "$GITHUB_WORKSPACE/sfw-artifacts/sfw-report.json" + echo "Collected SFW report -> sfw-artifacts/sfw-report.json" + else + echo "No SFW JSON report found at '${SFW_JSON_REPORT_PATH:-}'." \ + > "$GITHUB_WORKSPACE/sfw-artifacts/sfw-report-missing.txt" + fi + + - name: Upload SFW report artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socket-firewall-enterprise-${{ github.event.pull_request.number }} + path: sfw-artifacts/ + if-no-files-found: warn + retention-days: 14 + + workflow-notice: + needs: inspect + if: needs.inspect.outputs.workflow_or_action_changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Flag workflow-sensitive updates + run: | + { + echo "## Sensitive File Notice" + echo "This PR changes workflow, composite-action, or dependabot config files." + echo "Require explicit human review before merge." + } >> "$GITHUB_STEP_SUMMARY" + + # Aggregator gate -- the single check intended to become the required status + # check on main. The Socket Firewall smoke jobs are conditional (deps-changed + # gates them, and exactly one of free/enterprise runs per PR), so neither can + # be required directly: a required check whose job is `if:`-skipped is never + # created and sits at "Expected -- Waiting for status to be reported" + # forever, permanently blocking merge (this hits every Dependabot/fork PR and + # every PR that doesn't touch deps). + # + # This job runs unconditionally (`if: always()`), depends on all the + # conditional jobs, and fails ONLY when one of them actually failed or was + # cancelled. A `skipped` dependency passes -- so the gate is green when no + # deps changed, and otherwise satisfied by whichever smoke path ran (free for + # Dependabot/forks, enterprise for trusted maintainers). A real Socket + # Firewall block surfaces as a smoke-job failure and thus a gate failure. + # + # NOT YET wired into branch protection -- added during a soak period so the + # check is visible before it becomes blocking. Requiring it before it lands + # on main would strand every other open PR on the trap above. + sfw-gate: + name: Socket Firewall Gate + needs: [inspect, python-sfw-smoke-free, python-sfw-smoke-enterprise, workflow-notice] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Evaluate dependency-review results + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + echo "$NEEDS_JSON" + # Fail if and only if a needed job reported failure or cancelled; + # success and skipped both pass. jq returns the count of offending + # results. + bad="$(printf '%s' "$NEEDS_JSON" \ + | jq '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled")] | length')" + + { + echo "## Socket Firewall Gate" + printf '%s\n' "$NEEDS_JSON" | jq -r 'to_entries[] | "- \(.key): \(.value.result)"' + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$bad" -ne 0 ]; then + echo "Gate failed: $bad upstream job(s) failed or were cancelled." >> "$GITHUB_STEP_SUMMARY" + echo "::error::Socket Firewall Gate failed -- $bad upstream job(s) failed or were cancelled." + exit 1 + fi + + echo "Gate passed." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 12b38da..73849d8 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -3,27 +3,37 @@ on: pull_request: types: [opened, synchronize, ready_for_review] +# Cancel an in-flight preview when the PR is pushed again -- previews publish +# to Test PyPI, so superseded runs shouldn't keep churning. +concurrency: + group: pr-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: preview: + # Skip on: + # - PRs from forks (no access to publish secrets / OIDC) + # - Dependabot PRs: preview-publishing a dependency bump to Test PyPI is + # pointless (no package version bump) and would fail the version check. + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' runs-on: ubuntu-latest permissions: id-token: write contents: read pull-requests: write steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.x' + python-version: '3.13' - # Install all dependencies from pyproject.toml - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install hatchling==1.27.0 - pip install hatch==1.14.0 + - name: Install build tooling + uses: ./.github/actions/setup-hatch - name: Inject full dynamic version run: python .hooks/sync_version.py --dev @@ -64,7 +74,7 @@ jobs: - name: Comment on PR if: steps.version_check.outputs.exists != 'true' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: VERSION: ${{ env.VERSION }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fa6c1b..322ae27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,27 +10,26 @@ jobs: id-token: write contents: read steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.x' + python-version: '3.13' + + - name: Install build tooling + uses: ./.github/actions/setup-hatch - # Install all dependencies from pyproject.toml - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install hatchling==1.27.0 - pip install hatch==1.14.0 - - name: Get Version id: version + env: + REF_NAME: ${{ github.ref_name }} run: | RAW_VERSION=$(hatch version) echo "VERSION=$RAW_VERSION" >> $GITHUB_ENV - if [ "v$RAW_VERSION" != "${{ github.ref_name }}" ]; then - echo "Error: Git tag (${{ github.ref_name }}) does not match hatch version (v$RAW_VERSION)" + if [ "v$RAW_VERSION" != "$REF_NAME" ]; then + echo "Error: Git tag ($REF_NAME) does not match hatch version (v$RAW_VERSION)" exit 1 fi diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 8fa9203..b7f2871 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -4,16 +4,26 @@ on: types: [opened, synchronize, ready_for_review] paths: - 'socketdev/**' - - 'setup.py' - 'pyproject.toml' + - 'uv.lock' + +permissions: + contents: read + pull-requests: write + issues: write jobs: check_version: + # Skip on Dependabot PRs: they bump dependencies (touching uv.lock / + # pyproject.toml) without bumping the package version, so the increment + # check would always fail. Package-version bumps come from maintainer PRs. + if: github.event.pull_request.user.login != 'dependabot[bot]' runs-on: ubuntu-latest steps: - - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Fetch all history for all branches + persist-credentials: false - name: Check version increment id: version_check @@ -27,19 +37,58 @@ jobs: MAIN_VERSION=$(grep -o "__version__.*" socketdev/version.py | awk '{print $3}' | tr -d '"' | tr -d "'") echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV - # Compare versions using Python - python3 -c " + export PR_VERSION + export MAIN_VERSION + + # Compare against both main and latest published PyPI release. + python3 <<'PY' + import json + import os + import urllib.request from packaging import version - pr_ver = version.parse('${PR_VERSION}') - main_ver = version.parse('${MAIN_VERSION}') - if pr_ver <= main_ver: - print(f'❌ Version must be incremented! Main: {main_ver}, PR: {pr_ver}') - exit(1) - print(f'✅ Version properly incremented from {main_ver} to {pr_ver}') - " + + pr_ver = version.parse(os.environ["PR_VERSION"]) + main_ver = version.parse(os.environ["MAIN_VERSION"]) + + with urllib.request.urlopen("https://pypi.org/pypi/socketdev/json") as response: + pypi_data = json.load(response) + + published_versions = [] + for raw in pypi_data.get("releases", {}).keys(): + parsed = version.parse(raw) + if not parsed.is_prerelease and not parsed.is_devrelease: + published_versions.append(parsed) + + pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0") + required_floor = max(main_ver, pypi_ver) + + if pr_ver <= required_floor: + print( + f"❌ Version must be greater than main and PyPI! " + f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + ) + raise SystemExit(1) + + print( + f"✅ Version properly incremented. " + f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + ) + PY + + - name: Require uv.lock update when pyproject changes + run: | + CHANGED_FILES="$(git diff --name-only origin/main...HEAD)" + + if echo "$CHANGED_FILES" | grep -qx 'pyproject.toml'; then + if ! echo "$CHANGED_FILES" | grep -qx 'uv.lock'; then + echo "❌ pyproject.toml changed, but uv.lock was not updated." + echo "Run 'uv lock' and commit uv.lock with the version bump." + exit 1 + fi + fi - name: Manage PR Comment - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 if: always() env: MAIN_VERSION: ${{ env.MAIN_VERSION }} diff --git a/.gitignore b/.gitignore index 9f566db..77da6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ dist *.egg-info *.cpython-312.pyc example-socket-export.py -__pycache__/ \ No newline at end of file +__pycache__/ +.coverage +.coverage.* +htmlcov/ diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index 59b0427..7a8ab24 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -8,10 +8,13 @@ VERSION_FILE = pathlib.Path("socketdev/version.py") PYPROJECT_FILE = pathlib.Path("pyproject.toml") +UV_LOCK_FILE = pathlib.Path("uv.lock") VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]") PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*".*"$', re.MULTILINE) -PYPI_API = "https://test.pypi.org/pypi/socketdev/json" +STABLE_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +PYPI_PROD_API = "https://pypi.org/pypi/socketdev/json" +PYPI_TEST_API = "https://test.pypi.org/pypi/socketdev/json" def read_version_from_version_file(path: pathlib.Path) -> str: content = path.read_text() @@ -38,17 +41,40 @@ def bump_patch_version(version: str) -> str: parts[-1] = str(int(parts[-1]) + 1) return ".".join(parts) -def fetch_existing_versions() -> set: +def parse_stable_version(version: str): + if not STABLE_VERSION_PATTERN.fullmatch(version): + return None + return tuple(int(part) for part in version.split(".")) + + +def format_stable_version(version_parts) -> str: + return ".".join(str(part) for part in version_parts) + + +def fetch_existing_versions(api_url: str) -> set: try: - with urllib.request.urlopen(PYPI_API) as response: + with urllib.request.urlopen(api_url) as response: data = json.load(response) return set(data.get("releases", {}).keys()) except Exception as e: - print(f"⚠️ Warning: Failed to fetch existing versions from Test PyPI: {e}") + print(f"⚠️ Warning: Failed to fetch versions from {api_url}: {e}") return set() + +def fetch_latest_stable_pypi_version(): + versions = fetch_existing_versions(PYPI_PROD_API) + stable_versions = [] + for ver in versions: + parsed = parse_stable_version(ver) + if parsed is not None: + stable_versions.append(parsed) + if not stable_versions: + return None + return max(stable_versions) + + def find_next_available_dev_version(base_version: str) -> str: - existing_versions = fetch_existing_versions() + existing_versions = fetch_existing_versions(PYPI_TEST_API) for i in range(1, 100): candidate = f"{base_version}.dev{i}" if candidate not in existing_versions: @@ -56,6 +82,20 @@ def find_next_available_dev_version(base_version: str) -> str: print("❌ Could not find available .devN slot after 100 attempts.") sys.exit(1) + +def find_next_stable_patch_version(current_version: str) -> str: + current_stable = current_version.split(".dev")[0] if ".dev" in current_version else current_version + current_parts = parse_stable_version(current_stable) + if current_parts is None: + print(f"❌ Unsupported version format for stable bump: {current_version}") + sys.exit(1) + + latest_pypi_parts = fetch_latest_stable_pypi_version() + base_parts = max([current_parts, latest_pypi_parts] if latest_pypi_parts else [current_parts]) + next_parts = (base_parts[0], base_parts[1], base_parts[2] + 1) + return format_stable_version(next_parts) + + def inject_version(version: str): print(f"🔁 Updating version to: {version}") @@ -68,6 +108,22 @@ def inject_version(version: str): new_pyproject = PYPROJECT_PATTERN.sub(f'version = "{version}"', pyproject) PYPROJECT_FILE.write_text(new_pyproject) + +def run_uv_lock() -> bool: + before = UV_LOCK_FILE.read_bytes() if UV_LOCK_FILE.exists() else b"" + try: + subprocess.run(["uv", "lock"], check=True, text=True) + except FileNotFoundError: + print("❌ `uv` is required but was not found in PATH.") + sys.exit(1) + except subprocess.CalledProcessError: + print("❌ `uv lock` failed. Please run it manually and fix any errors.") + sys.exit(1) + + after = UV_LOCK_FILE.read_bytes() if UV_LOCK_FILE.exists() else b"" + return before != after + + def main(): dev_mode = "--dev" in sys.argv current_version = read_version_from_version_file(VERSION_FILE) @@ -80,15 +136,36 @@ def main(): base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version new_version = find_next_available_dev_version(base_version) inject_version(new_version) - print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + uv_lock_changed = run_uv_lock() + lock_hint = " and uv.lock" if uv_lock_changed else "" + print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.") sys.exit(0) else: - new_version = bump_patch_version(current_version) + new_version = find_next_stable_patch_version(current_version) inject_version(new_version) - print("⚠️ Version was unchanged — auto-bumped. Please git add + commit again.") + uv_lock_changed = run_uv_lock() + lock_hint = " and uv.lock" if uv_lock_changed else "" + print(f"⚠️ Version was unchanged — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.") sys.exit(1) else: - print("✅ Version already bumped — proceeding.") + if not dev_mode: + current_parts = parse_stable_version(current_version) + latest_pypi_parts = fetch_latest_stable_pypi_version() + if current_parts is not None and latest_pypi_parts is not None and current_parts <= latest_pypi_parts: + next_parts = (latest_pypi_parts[0], latest_pypi_parts[1], latest_pypi_parts[2] + 1) + new_version = format_stable_version(next_parts) + inject_version(new_version) + uv_lock_changed = run_uv_lock() + lock_hint = " and uv.lock" if uv_lock_changed else "" + print(f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.") + sys.exit(1) + + uv_lock_changed = run_uv_lock() + if uv_lock_changed: + print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.") + sys.exit(1) + + print("✅ Version already bumped and uv.lock is up to date — proceeding.") sys.exit(0) if __name__ == "__main__": diff --git a/README.rst b/README.rst index fa40940..61b64ce 100644 --- a/README.rst +++ b/README.rst @@ -28,9 +28,11 @@ Supported Functions ------------------- -purl.post(license, components) -"""""""""""""""""""""""""""""" -Retrieve the package information for a purl post +purl.post(license, components, org_slug=None) +""""""""""""""""""""""""""""""""""""""""""""" +Retrieve package information for one or more PURLs. Pass ``org_slug`` to use the +current org-scoped endpoint. Omitting ``org_slug`` keeps the legacy deprecated +endpoint for backwards compatibility. **Usage:** @@ -38,6 +40,7 @@ Retrieve the package information for a purl post from socketdev import socketdev socket = socketdev(token="REPLACE_ME") + org_slug = "your-org-slug" license = "true" components = [ { @@ -47,12 +50,13 @@ Retrieve the package information for a purl post "purl": "pkg:pypi/socketsecurity" } ] - print(socket.purl.post(license, components)) + print(socket.purl.post(license, components, org_slug=org_slug)) **PARAMETERS:** - **license (str)** - The license parameter if enabled will show alerts and license information. If disabled will only show the basic package metadata and scores. Default is true - **components (array{dict})** - The components list of packages urls +- **org_slug (str, optional)** - Organization slug for the supported org-scoped PURL endpoint. If omitted, the SDK uses the deprecated legacy endpoint for backwards compatibility. export.cdx_bom(org_slug, id, query_params) """""""""""""""""""""""""""""""""""""""""" @@ -184,6 +188,7 @@ Create a full scan from a set of package manifest files. Returns a full scan inc params = FullScanParams( org_slug="org_name", repo="TestRepo", + workspace="my-workspace", branch="main", commit_message="Test Commit Message", commit_hash="abc123def456", @@ -223,10 +228,14 @@ Create a full scan from a set of package manifest files. Returns a full scan inc +------------------------+------------+-------------------------------------------------------------------------------+ | tmp | False | Boolean temporary flag | +------------------------+------------+-------------------------------------------------------------------------------+ +| workspace | False | The workspace of the repository to associate the full-scan with. | ++------------------------+------------+-------------------------------------------------------------------------------+ | integration_type | False | IntegrationType enum value (e.g., "api", "github") | +------------------------+------------+-------------------------------------------------------------------------------+ | integration_org_slug | False | Organization slug for integration | +------------------------+------------+-------------------------------------------------------------------------------+ +| scan_type | False | ScanType enum value: "socket", "socket_tier1", or "socket_basics" | ++------------------------+------------+-------------------------------------------------------------------------------+ fullscans.delete(org_slug, full_scan_id) """""""""""""""""""""""""""""""""""""""" diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..39e12bc --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/socketdev/socket-sdk-python", + "public_key": "pk_9HRbh6e3q2AL9xuPAiJYT" +} diff --git a/pyproject.toml b/pyproject.toml index 6f05c7b..609daa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "socketdev" -version = "3.0.28" +version = "3.3.0" requires-python = ">= 3.9" dependencies = [ 'requests', @@ -23,7 +23,6 @@ maintainers = [ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -51,7 +50,7 @@ test = [ ] [project.urls] -Homepage = "https://github.com/socketdev/socketdev" +Homepage = "https://github.com/SocketDev/socket-sdk-python" [tool.ruff] # Exclude a variety of commonly ignored directories. diff --git a/socketdev/core/api.py b/socketdev/core/api.py index 575e086..8c35231 100644 --- a/socketdev/core/api.py +++ b/socketdev/core/api.py @@ -76,26 +76,26 @@ def format_headers(headers_dict): path_str = f"\nPath: {url}" if response.status_code == 401: - raise APIAccessDenied(f"Unauthorized{path_str}{headers_str}") + raise APIAccessDenied(f"Unauthorized{path_str}{headers_str}", status_code=401) if response.status_code == 403: try: error_message = response.json().get("error", {}).get("message", "") if "Insufficient permissions for API method" in error_message: log.error(f"{error_message}{path_str}{headers_str}") - raise APIInsufficientPermissions() + raise APIInsufficientPermissions(status_code=403) elif "Organization not allowed" in error_message: log.error(f"{error_message}{path_str}{headers_str}") - raise APIOrganizationNotAllowed() + raise APIOrganizationNotAllowed(status_code=403) elif "Insufficient max quota" in error_message: log.error(f"{error_message}{path_str}{headers_str}") - raise APIInsufficientQuota() + raise APIInsufficientQuota(status_code=403) else: - raise APIAccessDenied(f"{error_message or 'Access denied'}{path_str}{headers_str}") + raise APIAccessDenied(f"{error_message or 'Access denied'}{path_str}{headers_str}", status_code=403) except ValueError: - raise APIAccessDenied(f"Access denied{path_str}{headers_str}") + raise APIAccessDenied(f"Access denied{path_str}{headers_str}", status_code=403) if response.status_code == 404: log.error(f"Path not found {path}{path_str}{headers_str}") - raise APIResourceNotFound() + raise APIResourceNotFound(status_code=404) if response.status_code == 429: retry_after = response.headers.get("retry-after") if retry_after: @@ -109,7 +109,7 @@ def format_headers(headers_dict): else: time_msg = "" log.error(f"Insufficient quota for API route.{time_msg}{path_str}{headers_str}") - raise APIInsufficientQuota() + raise APIInsufficientQuota(status_code=429) if response.status_code == 502: log.error(f"Upstream server error{path_str}{headers_str}") raise APIBadGateway() @@ -124,7 +124,7 @@ def format_headers(headers_dict): f"Error message: {error_message}" ) log.error(error) - raise APIFailure(error) + raise APIFailure(error, status_code=response.status_code) return response except Timeout: diff --git a/socketdev/core/issues.py b/socketdev/core/issues.py index d712056..027ad98 100644 --- a/socketdev/core/issues.py +++ b/socketdev/core/issues.py @@ -463,7 +463,7 @@ class didYouMean: def __init__(self): self.description = "Package name is similar to other popular packages and may not be the package you want." - self.props = {"alternatePackage": "Alternate package", "downloads": "Downloads", "downloadsRatio": "Download ratio", "editDistance": "Edit distance"} + self.props = {"alternatePackage": "Alternate package", "detectedAt": "Detected at"} self.suggestion = "Use care when consuming similarly named packages and ensure that you did not intend to consume a different package. Malicious packages often publish using similar names as existing popular packages." self.title = "Possible typosquat attack" self.emoji = "\ud83e\uddd0" diff --git a/socketdev/exceptions.py b/socketdev/exceptions.py index d61b827..980aaf9 100644 --- a/socketdev/exceptions.py +++ b/socketdev/exceptions.py @@ -1,47 +1,80 @@ -class APIFailure(Exception): - """Base exception for all Socket API errors""" - pass - - -class APIKeyMissing(APIFailure): - """Raised when the api key is not passed and the headers are empty""" - - -class APIAccessDenied(APIFailure): - """Raised when access is denied to the API""" - pass - - -class APIInsufficientPermissions(APIFailure): - """Raised when the API token doesn't have required permissions""" - pass - - -class APIOrganizationNotAllowed(APIFailure): - """Raised when organization doesn't have access to the feature""" - pass - - -class APIInsufficientQuota(APIFailure): - """Raised when access is denied to the API due to quota limits""" - pass - - -class APIResourceNotFound(APIFailure): - """Raised when the requested resource is not found""" - pass - - -class APITimeout(APIFailure): - """Raised when a request times out""" - pass - - -class APIConnectionError(APIFailure): - """Raised when there's a connection error""" - pass - - -class APIBadGateway(APIFailure): - """Raised when the upstream server returns a 502 Bad Gateway error""" - pass \ No newline at end of file +from typing import Optional + +# HTTP statuses classified as transient by APIFailure.is_transient_error(): gateway / +# availability failures where the request was dropped before the application produced a +# definitive response, so retrying the same request may succeed (408 Request Timeout, +# 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout). +TRANSIENT_HTTP_STATUS_CODES = frozenset({408, 502, 503, 504}) + + +class APIFailure(Exception): + """Base exception for all Socket API errors""" + + def __init__(self, *args, status_code: Optional[int] = None): + super().__init__(*args) + self.status_code = status_code + + def is_transient_error(self) -> bool: + """Whether this failure is transient, i.e. retrying the same request may succeed. + + Transient failures happen at the gateway/connection level - HTTP 408/502/503/504, + dropped or reset connections, and client-side timeouts - before the server produced + a definitive response. Deterministic errors (e.g. 400/401/403/404/429) are not + transient: retrying the same request fails the same way. Classification is based on + the HTTP status code recorded when the exception was raised (or overridden by + subclasses without an HTTP status, like timeouts), so it stays correct even if a + status code gains a dedicated exception subclass later. + """ + return self.status_code in TRANSIENT_HTTP_STATUS_CODES + + +class APIKeyMissing(APIFailure): + """Raised when the api key is not passed and the headers are empty""" + + +class APIAccessDenied(APIFailure): + """Raised when access is denied to the API""" + pass + + +class APIInsufficientPermissions(APIFailure): + """Raised when the API token doesn't have required permissions""" + pass + + +class APIOrganizationNotAllowed(APIFailure): + """Raised when organization doesn't have access to the feature""" + pass + + +class APIInsufficientQuota(APIFailure): + """Raised when access is denied to the API due to quota limits""" + pass + + +class APIResourceNotFound(APIFailure): + """Raised when the requested resource is not found""" + pass + + +class APITimeout(APIFailure): + """Raised when a request times out""" + + def is_transient_error(self) -> bool: + # No HTTP status: the request timed out client-side, so a retry may succeed. + return True + + +class APIConnectionError(APIFailure): + """Raised when there's a connection error""" + + def is_transient_error(self) -> bool: + # No HTTP status: the connection was dropped/reset mid-request, so a retry may succeed. + return True + + +class APIBadGateway(APIFailure): + """Raised when the upstream server returns a 502 Bad Gateway error""" + + def __init__(self, *args): + super().__init__(*args, status_code=502) diff --git a/socketdev/fullscans/__init__.py b/socketdev/fullscans/__init__.py index 4845295..dd4c6b7 100644 --- a/socketdev/fullscans/__init__.py +++ b/socketdev/fullscans/__init__.py @@ -31,6 +31,7 @@ class SocketCategory(str, Enum): VULNERABILITY = "vulnerability" LICENSE = "license" MISCELLANEOUS = "miscellaneous" + OTHER = "other" # Added to match backend API responses class DiffType(str, Enum): @@ -41,6 +42,12 @@ class DiffType(str, Enum): UPDATED = "updated" +class ScanType(str, Enum): + SOCKET = "socket" + SOCKET_TIER1 = "socket_tier1" + SOCKET_BASICS = "socket_basics" + + @dataclass(kw_only=True) class SocketPURL: type: SocketPURL_Type @@ -99,6 +106,8 @@ class FullScanParams: make_default_branch: Optional[bool] = None set_as_pending_head: Optional[bool] = None tmp: Optional[bool] = None + scan_type: Optional[ScanType] = None + workspace: Optional[str] = None def __getitem__(self, key): return getattr(self, key) @@ -109,6 +118,7 @@ def to_dict(self): @classmethod def from_dict(cls, data: dict) -> "FullScanParams": integration_type = data.get("integration_type") + scan_type = data.get("scan_type") return cls( repo=data["repo"], org_slug=data.get("org_slug"), @@ -122,6 +132,8 @@ def from_dict(cls, data: dict) -> "FullScanParams": make_default_branch=data.get("make_default_branch"), set_as_pending_head=data.get("set_as_pending_head"), tmp=data.get("tmp"), + scan_type=ScanType(scan_type) if scan_type is not None else None, + workspace=data.get("workspace"), ) @@ -432,11 +444,20 @@ def to_dict(self): @classmethod def from_dict(cls, data: dict) -> "SocketAlert": + try: + category = SocketCategory(data["category"]) + except ValueError: + log.warning( + "Unknown SocketCategory %r; falling back to MISCELLANEOUS. " + "Upgrade socketdev to pick up newer categories.", + data["category"], + ) + category = SocketCategory.MISCELLANEOUS return cls( key=data["key"], type=data["type"], severity=SocketIssueSeverity(data["severity"]), - category=SocketCategory(data["category"]), + category=category, file=data.get("file"), start=data.get("start"), end=data.get("end"), @@ -797,6 +818,11 @@ def post( ): print("Removing pull_request param from FullScanParams as it is None, 0, or not an integer") params_dict.pop("pull_request") + + if hasattr(params, 'workspace') and params.workspace is None: + print("Removing workspace param from FullScanParams as it is None") + params_dict.pop("workspace") + params_arg = urllib.parse.urlencode(params_dict) path = "orgs/" + org_slug + "/full-scans?" + str(params_arg) diff --git a/socketdev/purl/__init__.py b/socketdev/purl/__init__.py index 555f3a5..50118ed 100644 --- a/socketdev/purl/__init__.py +++ b/socketdev/purl/__init__.py @@ -1,5 +1,6 @@ import json import urllib.parse +import warnings from socketdev.log import log from ..core.dedupe import Dedupe @@ -8,8 +9,21 @@ class Purl: def __init__(self, api): self.api = api - def post(self, license: str = "false", components: list = None, **kwargs) -> list: - path = "purl?" + def post( + self, + license: str = "false", + components: list = None, + org_slug: str = None, + **kwargs, + ) -> list: + if org_slug is None: + warnings.warn( + "Calling purl.post() without org_slug uses the deprecated POST /v0/purl endpoint. " + "Pass org_slug to migrate to POST /v0/orgs/{org_slug}/purl.", + DeprecationWarning, + stacklevel=2, + ) + path = f"orgs/{org_slug}/purl?" if org_slug else "purl?" if components is None: components = [] purls = {"components": components} diff --git a/socketdev/version.py b/socketdev/version.py index c6dfd40..88c513e 100644 --- a/socketdev/version.py +++ b/socketdev/version.py @@ -1 +1 @@ -__version__ = "3.0.28" +__version__ = "3.3.0" diff --git a/tests/integration/test_all_endpoints.py b/tests/integration/test_all_endpoints.py index 17d934f..e36f074 100644 --- a/tests/integration/test_all_endpoints.py +++ b/tests/integration/test_all_endpoints.py @@ -181,13 +181,13 @@ def test_historical_trend_mocked(self): def test_npm_issues_mocked(self): """Test npm issues endpoint.""" self._mock_success_response([{"type": "security", "severity": "high"}]) - result = self.sdk.npm.issues("lodash", "4.17.21") + result = self.sdk.npm.issues("lodash", "4.18.1") self.assertIsInstance(result, list) def test_npm_score_mocked(self): """Test npm score endpoint.""" self._mock_success_response([{"category": "security", "value": 85}]) - result = self.sdk.npm.score("lodash", "4.17.21") + result = self.sdk.npm.score("lodash", "4.18.1") self.assertIsInstance(result, list) # OpenAPI endpoints @@ -206,9 +206,13 @@ def test_org_get_mocked(self): # PURL endpoints def test_purl_post_mocked(self): - """Test purl post endpoint.""" - self._mock_success_response([{"purl": "pkg:npm/lodash@4.17.21", "valid": True}]) - result = self.sdk.purl.post("false", [{"purl": "pkg:npm/lodash@4.17.21"}]) + """Test org-scoped purl post endpoint.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {'content-type': 'application/x-ndjson'} + mock_response.text = '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", "type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' + self.mock_requests.request.return_value = mock_response + result = self.sdk.purl.post("false", [{"purl": "pkg:npm/lodash@4.18.1"}], org_slug="test-org") self.assertIsInstance(result, list) # Quota endpoints @@ -372,7 +376,7 @@ def setUpClass(cls): test_package = { "name": "test-integration-package", "version": "1.0.0", - "dependencies": {"lodash": "4.17.21"} + "dependencies": {"lodash": "4.18.1"} } with open(cls.package_json_path, 'w') as f: json.dump(test_package, f, indent=2) @@ -414,20 +418,20 @@ def test_openapi_get_integration(self): # NPM endpoints (should work for public packages) def test_npm_issues_integration(self): """Test npm issues endpoint.""" - result = self._try_endpoint(self.sdk.npm.issues, "lodash", "4.17.21") + result = self._try_endpoint(self.sdk.npm.issues, "lodash", "4.18.1") if result: self.assertIsInstance(result, list) def test_npm_score_integration(self): """Test npm score endpoint.""" - result = self._try_endpoint(self.sdk.npm.score, "lodash", "4.17.21") + result = self._try_endpoint(self.sdk.npm.score, "lodash", "4.18.1") if result: self.assertIsInstance(result, list) # PURL endpoints def test_purl_post_integration(self): """Test purl post endpoint.""" - components = [{"purl": "pkg:npm/lodash@4.17.21"}] + components = [{"purl": "pkg:npm/lodash@4.18.1"}] result = self._try_endpoint(self.sdk.purl.post, "false", components) if result: self.assertIsInstance(result, list) @@ -515,7 +519,7 @@ def test_dependencies_get_integration(self): """Test dependencies get endpoint.""" result = self._try_endpoint( self.sdk.dependencies.get, - self.org_slug, "npm", "lodash", "4.17.21" + self.org_slug, "npm", "lodash", "4.18.1" ) if result: self.assertIsInstance(result, dict) diff --git a/tests/integration/test_comprehensive_integration.py b/tests/integration/test_comprehensive_integration.py index 137723b..4259b6b 100644 --- a/tests/integration/test_comprehensive_integration.py +++ b/tests/integration/test_comprehensive_integration.py @@ -53,7 +53,7 @@ def setUpClass(cls): "name": "test-integration-project", "version": "1.0.0", "dependencies": { - "lodash": "4.17.21" + "lodash": "4.18.1" } } @@ -264,7 +264,7 @@ def test_npm_endpoints(self): """Test NPM-related endpoints.""" # Test getting package issues - this should work for most packages try: - issues = self.sdk.npm.issues("lodash", "4.17.21") + issues = self.sdk.npm.issues("lodash", "4.18.1") self.assertIsInstance(issues, dict) except Exception as e: print(f"NPM issues endpoint not available: {e}") @@ -281,9 +281,13 @@ def test_purl_endpoint(self): """Test PURL (Package URL) functionality.""" try: # Test with a common npm package - purl = "pkg:npm/lodash@4.17.21" - result = self.sdk.purl.post([purl]) - self.assertIsInstance(result, dict) + purl = "pkg:npm/lodash@4.18.1" + result = self.sdk.purl.post( + license="false", + components=[{"purl": purl}], + org_slug=self.org_slug, + ) + self.assertIsInstance(result, list) except Exception as e: print(f"PURL endpoint not available: {e}") diff --git a/tests/unit/test_all_endpoints_unit.py b/tests/unit/test_all_endpoints_unit.py index 40f90bc..64895ee 100644 --- a/tests/unit/test_all_endpoints_unit.py +++ b/tests/unit/test_all_endpoints_unit.py @@ -44,11 +44,11 @@ def _mock_response(self, data=None, status_code=200): # Dependencies endpoints def test_dependencies_post_unit(self): """Test dependencies post with proper file handling.""" - expected_data = {"packages": [{"name": "lodash", "version": "4.17.21"}]} + expected_data = {"packages": [{"name": "lodash", "version": "4.18.1"}]} self._mock_response(expected_data) with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - json.dump({"name": "test-package", "dependencies": {"lodash": "4.17.21"}}, f) + json.dump({"name": "test-package", "dependencies": {"lodash": "4.18.1"}}, f) f.flush() try: @@ -72,12 +72,12 @@ def test_dependencies_get_unit(self): expected_data = {"dependencies": [{"name": "sub-dependency", "version": "1.0.0"}]} self._mock_response(expected_data) - result = self.sdk.dependencies.get("test-org", "npm", "lodash", "4.17.21") + result = self.sdk.dependencies.get("test-org", "npm", "lodash", "4.18.1") self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") - self.assertIn("/orgs/test-org/dependencies/npm/lodash/4.17.21", call_args[0][1]) + self.assertIn("/orgs/test-org/dependencies/npm/lodash/4.18.1", call_args[0][1]) # DiffScans endpoints def test_diffscans_list_unit(self): @@ -305,24 +305,24 @@ def test_npm_issues_unit(self): expected_data = [{"type": "security", "severity": "high", "title": "Test issue"}] self._mock_response(expected_data) - result = self.sdk.npm.issues("lodash", "4.17.21") + result = self.sdk.npm.issues("lodash", "4.18.1") self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") - self.assertIn("/npm/lodash/4.17.21/issues", call_args[0][1]) + self.assertIn("/npm/lodash/4.18.1/issues", call_args[0][1]) def test_npm_score_unit(self): """Test npm score endpoint.""" expected_data = [{"category": "security", "value": 85}] self._mock_response(expected_data) - result = self.sdk.npm.score("lodash", "4.17.21") + result = self.sdk.npm.score("lodash", "4.18.1") self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") - self.assertIn("/npm/lodash/4.17.21/score", call_args[0][1]) + self.assertIn("/npm/lodash/4.18.1/score", call_args[0][1]) # OpenAPI endpoints def test_openapi_get_unit(self): @@ -352,14 +352,14 @@ def test_org_get_unit(self): # PURL endpoints def test_purl_post_unit(self): - """Test PURL validation endpoint.""" + """Test org-scoped PURL validation endpoint.""" # Expected final result after deduplication - should match what the dedupe function produces expected_data = [{ - "inputPurl": "pkg:npm/lodash@4.17.21", - "purl": "pkg:npm/lodash@4.17.21", + "inputPurl": "pkg:npm/lodash@4.18.1", + "purl": "pkg:npm/lodash@4.18.1", "type": "npm", "name": "lodash", - "version": "4.17.21", + "version": "4.18.1", "valid": True, "alerts": [], "releases": ["npm"] @@ -367,7 +367,7 @@ def test_purl_post_unit(self): # Mock the NDJSON response that would come from the actual API # This simulates what the API returns: newline-delimited JSON with SocketArtifact objects - mock_ndjson_response = '{"inputPurl": "pkg:npm/lodash@4.17.21", "purl": "pkg:npm/lodash@4.17.21", "type": "npm", "name": "lodash", "version": "4.17.21", "valid": true, "alerts": []}' + mock_ndjson_response = '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", "type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' # Mock the response with NDJSON format mock_response = Mock() @@ -376,13 +376,30 @@ def test_purl_post_unit(self): mock_response.text = mock_ndjson_response self.mock_requests.request.return_value = mock_response - components = [{"purl": "pkg:npm/lodash@4.17.21"}] - result = self.sdk.purl.post("false", components) + components = [{"purl": "pkg:npm/lodash@4.18.1"}] + result = self.sdk.purl.post("false", components, org_slug="test-org") self.assertEqual(result, expected_data) + call_args = self.mock_requests.request.call_args + self.assertEqual(call_args[0][0], "POST") + self.assertIn("/orgs/test-org/purl", call_args[0][1]) + + def test_purl_post_unit_legacy_path(self): + """Test legacy PURL validation endpoint remains available for compatibility.""" + mock_ndjson_response = '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", "type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {'content-type': 'application/x-ndjson'} + mock_response.text = mock_ndjson_response + self.mock_requests.request.return_value = mock_response + + self.sdk.purl.post("false", [{"purl": "pkg:npm/lodash@4.18.1"}]) + call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "POST") self.assertIn("/purl", call_args[0][1]) + self.assertNotIn("/orgs/", call_args[0][1]) # Quota endpoints def test_quota_get_unit(self): diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..df621ad --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,176 @@ +""" +Unit tests for the SDK exception hierarchy and transient-error classification. + +`APIFailure.is_transient_error()` tells consumers whether retrying the same request may +succeed (gateway/connection-level failures: HTTP 408/502/503/504, dropped or reset +connections, client-side timeouts) or whether the failure is deterministic (400/401/403/ +404/429 and similar). Classification is based on the `status_code` recorded at raise time +inside `API.do_request`, so these tests cover both the exception classes themselves and +the status codes `do_request` attaches when raising them. + +Run with: python -m pytest tests/unit/ -v +""" + +import unittest +from unittest.mock import MagicMock, patch + +import requests + +from socketdev.core.api import API +from socketdev.exceptions import ( + APIAccessDenied, + APIBadGateway, + APIConnectionError, + APIFailure, + APIInsufficientPermissions, + APIInsufficientQuota, + APIOrganizationNotAllowed, + APIResourceNotFound, + APITimeout, +) + + +class TestIsTransientError(unittest.TestCase): + """Classification of exceptions constructed directly.""" + + def test_transient_statuses_on_catch_all_failure(self): + for status in (408, 502, 503, 504): + self.assertTrue(APIFailure("boom", status_code=status).is_transient_error()) + + def test_deterministic_statuses_on_catch_all_failure(self): + for status in (400, 401, 403, 404, 422, 429, 500): + self.assertFalse(APIFailure("boom", status_code=status).is_transient_error()) + + def test_no_status_code_is_not_transient(self): + # The wrapped-unexpected-error case: do_request raises a bare APIFailure(). + self.assertFalse(APIFailure().is_transient_error()) + self.assertFalse(APIFailure("boom").is_transient_error()) + + def test_connection_level_classes_are_transient(self): + self.assertTrue(APITimeout().is_transient_error()) + self.assertTrue(APIConnectionError().is_transient_error()) + self.assertTrue(APIBadGateway().is_transient_error()) + + def test_bad_gateway_carries_502_by_default(self): + self.assertEqual(APIBadGateway().status_code, 502) + + def test_dedicated_4xx_classes_are_not_transient(self): + self.assertFalse(APIAccessDenied("denied", status_code=401).is_transient_error()) + self.assertFalse(APIInsufficientPermissions(status_code=403).is_transient_error()) + self.assertFalse(APIOrganizationNotAllowed(status_code=403).is_transient_error()) + self.assertFalse(APIInsufficientQuota(status_code=429).is_transient_error()) + self.assertFalse(APIResourceNotFound(status_code=404).is_transient_error()) + + def test_subclass_with_transient_status_follows_the_status(self): + # Classification is by recorded status, not class identity: if a transient status + # ever gains a dedicated subclass, is_transient_error() keeps working unchanged. + class APIServiceUnavailable(APIFailure): + pass + + self.assertTrue(APIServiceUnavailable(status_code=503).is_transient_error()) + + def test_message_text_does_not_affect_classification(self): + self.assertFalse( + APIFailure("original_status_code:503 lookalike").is_transient_error() + ) + + def test_single_message_arg_is_preserved(self): + error = APIFailure("something broke", status_code=503) + self.assertEqual(str(error), "something broke") + + +def _mock_response(status_code, json_data=None, headers=None, text=""): + response = MagicMock() + response.status_code = status_code + response.headers = headers if headers is not None else {} + response.text = text + if json_data is None: + response.json.side_effect = ValueError("no json") + else: + response.json.return_value = json_data + return response + + +class TestDoRequestStatusCodes(unittest.TestCase): + """do_request attaches the HTTP status to the exceptions it raises.""" + + def setUp(self): + self.api = API() + self.api.encode_key("test-token") + + def _do_request_raising(self, expected_class, response=None, side_effect=None): + with patch("socketdev.core.api.requests.request") as mock_request: + if side_effect is not None: + mock_request.side_effect = side_effect + else: + mock_request.return_value = response + with self.assertRaises(expected_class) as ctx: + self.api.do_request("orgs/test/full-scans", method="POST") + return ctx.exception + + def test_401_access_denied_is_not_transient(self): + error = self._do_request_raising(APIAccessDenied, _mock_response(401)) + self.assertEqual(error.status_code, 401) + self.assertFalse(error.is_transient_error()) + + def test_403_insufficient_permissions_is_not_transient(self): + response = _mock_response( + 403, + json_data={"error": {"message": "Insufficient permissions for API method"}}, + ) + error = self._do_request_raising(APIInsufficientPermissions, response) + self.assertEqual(error.status_code, 403) + self.assertFalse(error.is_transient_error()) + + def test_404_not_found_is_not_transient(self): + error = self._do_request_raising(APIResourceNotFound, _mock_response(404)) + self.assertEqual(error.status_code, 404) + self.assertFalse(error.is_transient_error()) + + def test_429_quota_is_not_transient(self): + error = self._do_request_raising(APIInsufficientQuota, _mock_response(429)) + self.assertEqual(error.status_code, 429) + self.assertFalse(error.is_transient_error()) + + def test_502_bad_gateway_is_transient(self): + error = self._do_request_raising(APIBadGateway, _mock_response(502)) + self.assertEqual(error.status_code, 502) + self.assertTrue(error.is_transient_error()) + + def test_catch_all_transient_statuses(self): + for status in (408, 503, 504): + error = self._do_request_raising(APIFailure, _mock_response(status)) + self.assertIs(type(error), APIFailure) + self.assertEqual(error.status_code, status) + self.assertTrue(error.is_transient_error()) + + def test_catch_all_deterministic_statuses(self): + for status in (400, 500): + error = self._do_request_raising(APIFailure, _mock_response(status)) + self.assertIs(type(error), APIFailure) + self.assertEqual(error.status_code, status) + self.assertFalse(error.is_transient_error()) + + def test_timeout_is_transient(self): + error = self._do_request_raising( + APITimeout, side_effect=requests.exceptions.Timeout("timed out") + ) + self.assertIsNone(error.status_code) + self.assertTrue(error.is_transient_error()) + + def test_connection_error_is_transient(self): + error = self._do_request_raising( + APIConnectionError, + side_effect=requests.exceptions.ConnectionError("reset"), + ) + self.assertIsNone(error.status_code) + self.assertTrue(error.is_transient_error()) + + def test_unexpected_error_wrapped_without_status_is_not_transient(self): + error = self._do_request_raising(APIFailure, side_effect=RuntimeError("boom")) + self.assertIsNone(error.status_code) + self.assertFalse(error.is_transient_error()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_issues_did_you_mean_props.py b/tests/unit/test_issues_did_you_mean_props.py new file mode 100644 index 0000000..8657872 --- /dev/null +++ b/tests/unit/test_issues_did_you_mean_props.py @@ -0,0 +1,28 @@ +"""Contract test for the didYouMean alert-type class's props. + +The OpenAPI schema (`socket-sdk-js/openapi.json` around line 9298) declares +that the API emits `didYouMean` alerts with ``props: { alternatePackage, +detectedAt }``. The Python SDK previously declared four props +(``alternatePackage``, ``downloads``, ``downloadsRatio``, ``editDistance``); +the latter three are no longer in the API schema and were dead keys at +runtime — and ``detectedAt`` was missing. + +Tracks CUS2-5. Sibling of CUS2-4. +""" + +import unittest + +from socketdev.core.issues import didYouMean + + +class TestDidYouMeanProps(unittest.TestCase): + def test_props_match_openapi_schema(self): + """API emits props { alternatePackage, detectedAt } (openapi.json:9298).""" + issue = didYouMean() + self.assertEqual(set(issue.props.keys()), {"alternatePackage", "detectedAt"}) + + def test_props_label_strings_are_non_empty(self): + """Every props key must have a non-empty human-readable label.""" + issue = didYouMean() + for key, label in issue.props.items(): + self.assertTrue(label, f"props[{key!r}] label should not be empty") diff --git a/tests/unit/test_socket_alert_category.py b/tests/unit/test_socket_alert_category.py new file mode 100644 index 0000000..c55113b --- /dev/null +++ b/tests/unit/test_socket_alert_category.py @@ -0,0 +1,69 @@ +""" +Unit tests for lenient SocketCategory parsing in SocketAlert.from_dict. + +Regression coverage for +https://github.com/SocketDev/socket-sdk-python/issues/78: the Socket API can +emit category values the SDK does not yet know about (e.g. ``"other"``). Strict +enum parsing turned that into a hard failure that took down every consumer +(notably socketsecurity CI runs) whenever a diff included one of those alerts. + +These tests pin the fallback behavior so the SDK stays forward-compatible with +new server-side categories. +""" + +import logging +import unittest + +from socketdev.fullscans import SocketAlert, SocketCategory, SocketIssueSeverity + + +class TestSocketAlertCategoryParsing(unittest.TestCase): + """SocketAlert.from_dict should tolerate unknown category values.""" + + def _base_payload(self, category: str) -> dict: + return { + "key": "alert-key", + "type": "someAlertType", + "severity": "low", + "category": category, + } + + def test_known_category_is_preserved(self): + alert = SocketAlert.from_dict(self._base_payload("supplyChainRisk")) + self.assertEqual(alert.category, SocketCategory.SUPPLY_CHAIN_RISK) + self.assertEqual(alert.severity, SocketIssueSeverity.LOW) + + def test_other_category_is_recognized(self): + # "other" is a known backend category as of CE-225; it should resolve to + # SocketCategory.OTHER rather than falling back to MISCELLANEOUS. + alert = SocketAlert.from_dict(self._base_payload("other")) + self.assertEqual(alert.category, SocketCategory.OTHER) + + def test_unknown_category_falls_back_to_miscellaneous(self): + alert = SocketAlert.from_dict(self._base_payload("somethingCompletelyNew")) + self.assertEqual(alert.category, SocketCategory.MISCELLANEOUS) + + def test_unknown_category_does_not_raise(self): + # Explicit regression assertion: no ValueError for brand-new categories. + try: + SocketAlert.from_dict(self._base_payload("somethingCompletelyNew")) + except ValueError as exc: + self.fail(f"SocketAlert.from_dict raised ValueError for unknown category: {exc}") + + def test_unknown_category_emits_warning(self): + with self.assertLogs("socketdev", level=logging.WARNING) as captured: + SocketAlert.from_dict(self._base_payload("somethingCompletelyNew")) + self.assertTrue( + any("Unknown SocketCategory" in message for message in captured.output), + f"expected a warning about the unknown category, got: {captured.output}", + ) + + def test_every_known_category_round_trips(self): + for category in SocketCategory: + with self.subTest(category=category): + alert = SocketAlert.from_dict(self._base_payload(category.value)) + self.assertEqual(alert.category, category) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_socket_sdk_unit.py b/tests/unit/test_socket_sdk_unit.py index b64aaec..973c046 100644 --- a/tests/unit/test_socket_sdk_unit.py +++ b/tests/unit/test_socket_sdk_unit.py @@ -110,13 +110,13 @@ def test_socket_purl_creation(self): type=SocketPURL_Type.NPM, name="lodash", namespace=None, - release="4.17.21" + release="4.18.1" ) self.assertEqual(purl.type, SocketPURL_Type.NPM) self.assertEqual(purl.name, "lodash") self.assertIsNone(purl.namespace) - self.assertEqual(purl.release, "4.17.21") + self.assertEqual(purl.release, "4.18.1") def test_integration_types(self): """Test that all integration types are available.""" @@ -223,7 +223,7 @@ def setUp(self): "name": "test-package", "version": "1.0.0", "dependencies": { - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/tests/unit/test_working_endpoints_unit.py b/tests/unit/test_working_endpoints_unit.py index e9d9d50..3f8112b 100644 --- a/tests/unit/test_working_endpoints_unit.py +++ b/tests/unit/test_working_endpoints_unit.py @@ -50,24 +50,24 @@ def test_npm_issues_unit(self): expected_data = [{"type": "security", "severity": "high"}] self._mock_response(expected_data) - result = self.sdk.npm.issues("lodash", "4.17.21") + result = self.sdk.npm.issues("lodash", "4.18.1") self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") - self.assertIn("/npm/lodash/4.17.21/issues", call_args[0][1]) + self.assertIn("/npm/lodash/4.18.1/issues", call_args[0][1]) def test_npm_score_unit(self): """Test NPM score endpoint - WORKING.""" expected_data = [{"category": "security", "value": 85}] self._mock_response(expected_data) - result = self.sdk.npm.score("lodash", "4.17.21") + result = self.sdk.npm.score("lodash", "4.18.1") self.assertEqual(result, expected_data) call_args = self.mock_requests.request.call_args self.assertEqual(call_args[0][0], "GET") - self.assertIn("/npm/lodash/4.17.21/score", call_args[0][1]) + self.assertIn("/npm/lodash/4.18.1/score", call_args[0][1]) def test_openapi_get_unit(self): """Test OpenAPI specification retrieval - WORKING.""" @@ -203,6 +203,38 @@ def test_fullscans_post_unit(self): finally: os.unlink(f.name) + def test_fullscans_post_with_workspace_unit(self): + """Test that workspace is included in the query string when set on FullScanParams.""" + expected_data = {"id": "new-scan"} + self._mock_response(expected_data, 201) + + params = FullScanParams( + repo="test-repo", + org_slug="test-org", + branch="main", + workspace="test-workspace", + ) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump({"name": "test", "version": "1.0.0"}, f) + f.flush() + + try: + with open(f.name, "rb") as file_obj: + files = [("file", ("package.json", file_obj))] + result = self.sdk.fullscans.post(files, params) + + self.assertEqual(result, expected_data) + call_args = self.mock_requests.request.call_args + self.assertEqual(call_args[0][0], "POST") + # Confirm workspace landed in the request URL query string + request_url = call_args[0][1] + self.assertIn("workspace=test-workspace", request_url) + self.assertIn("repo=test-repo", request_url) + + finally: + os.unlink(f.name) + def test_triage_list_alert_triage_unit(self): """Test triage list alerts - WORKING.""" expected_data = {"alerts": []} diff --git a/uv.lock b/uv.lock index 3f244df..7397d8f 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] [[package]] @@ -331,7 +332,8 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, @@ -370,7 +372,8 @@ name = "coverage" version = "7.10.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } wheels = [ @@ -593,51 +596,51 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, ] [[package]] @@ -675,7 +678,8 @@ name = "filelock" version = "3.19.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } wheels = [ @@ -708,7 +712,8 @@ name = "hatch" version = "1.15.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -769,7 +774,8 @@ name = "hatchling" version = "1.27.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "packaging", marker = "python_full_version < '3.10'" }, @@ -856,11 +862,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, ] [[package]] @@ -880,7 +886,8 @@ name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ @@ -968,7 +975,8 @@ name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "mdurl", marker = "python_full_version < '3.10'" }, @@ -1079,7 +1087,8 @@ name = "platformdirs" version = "4.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } wheels = [ @@ -1127,11 +1136,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -1148,7 +1157,8 @@ name = "pytest" version = "8.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, @@ -1187,7 +1197,7 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, @@ -1196,9 +1206,9 @@ dependencies = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1276,28 +1286,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -1305,7 +1314,8 @@ name = "secretstorage" version = "3.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", ] dependencies = [ { name = "cryptography", marker = "python_full_version < '3.10'" }, @@ -1343,7 +1353,7 @@ wheels = [ [[package]] name = "socketdev" -version = "3.0.23" +version = "3.3.0" source = { editable = "." } dependencies = [ { name = "requests" }, @@ -1486,11 +1496,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] @@ -1508,33 +1518,33 @@ wheels = [ [[package]] name = "uv" -version = "0.9.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/2b/4e2090bc3a6265b445b3d31ca6fff20c6458d11145069f7e48ade3e2d75b/uv-0.9.21.tar.gz", hash = "sha256:aa4ca6ccd68e81b5ebaa3684d3c4df2b51a982ac16211eadf0707741d36e6488", size = 3834762, upload-time = "2025-12-30T16:12:51.927Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/26/0750c5bb1637ebefe1db0936dc76ead8ce97f17368cda950642bfd90fa3f/uv-0.9.21-py3-none-linux_armv6l.whl", hash = "sha256:0b330eaced2fd9d94e2a70f3bb6c8fd7beadc9d9bf9f1227eb14da44039c413a", size = 21266556, upload-time = "2025-12-30T16:12:47.311Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ef/f019466c1e367ea68003cf35f4d44cc328694ed4a59b6004aa7dcacb2b35/uv-0.9.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1d8e0940bddd37a55f4479d61adaa6b302b780d473f037fc084e48b09a1678e7", size = 20485648, upload-time = "2025-12-30T16:12:15.746Z" }, - { url = "https://files.pythonhosted.org/packages/2a/41/f735bd9a5b4848b6f4f1028e6d768f581559d68eddb6403eb0f19ca4c843/uv-0.9.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cb420ddab7bcdd12c2352d4b551ced428d104311c0b98ce205675ab5c97072db", size = 18986976, upload-time = "2025-12-30T16:12:25.034Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5f/01d537e05927594dc379ff8bc04f8cde26384d25108a9f63758eae2a7936/uv-0.9.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a36d164438a6310c9fceebd041d80f7cffcc63ba80a7c83ee98394fadf2b8545", size = 20819312, upload-time = "2025-12-30T16:12:41.802Z" }, - { url = "https://files.pythonhosted.org/packages/18/89/9497395f57e007a2daed8172042ecccade3ff5569fd367d093f49bd6a4a8/uv-0.9.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c0ad83ce874cbbf9eda569ba793a9fb70870db426e9862300db8cf2950a7fe3b", size = 20900227, upload-time = "2025-12-30T16:12:19.242Z" }, - { url = "https://files.pythonhosted.org/packages/04/61/a3f6dfc75d278cce96b370e00b6f03d73ec260e5304f622504848bad219d/uv-0.9.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9076191c934b813147060e4cd97e33a58999de0f9c46f8ac67f614e154dae5c8", size = 21965424, upload-time = "2025-12-30T16:12:01.589Z" }, - { url = "https://files.pythonhosted.org/packages/18/3e/344e8c1078cfea82159c6608b8694f24fdfe850ce329a4708c026cb8b0ff/uv-0.9.21-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2ce0f6aca91f7fbf1192e43c063f4de3666fd43126aacc71ff7d5a79f831af59", size = 23540343, upload-time = "2025-12-30T16:12:13.139Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/5826659a81526687c6e5b5507f3f79f4f4b7e3022f3efae2ba36b19864c3/uv-0.9.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b4817642d5ef248b74ca7be3505e5e012a06be050669b80d1f7ced5ad50d188", size = 23171564, upload-time = "2025-12-30T16:12:22.219Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8d/404c54e019bb99ce474dc21e6b96c8a1351ba3c06e5e19fd8dcae0ba1899/uv-0.9.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb42237fa309d79905fb73f653f63c1fe45a51193411c614b13512cf5506df3", size = 22202400, upload-time = "2025-12-30T16:12:04.612Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f0/aa3d0081a2004050564364a1ef3277ddf889c9989a7278c0a9cce8284926/uv-0.9.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1d22f0ac03635d661e811c69d7c0b292751f90699acc6a1fb1509e17c936474", size = 22206448, upload-time = "2025-12-30T16:12:30.626Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a9/7a375e723a588f31f305ddf9ae2097af0b9dc7f7813641788b5b9764a237/uv-0.9.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:cdd805909d360ad67640201376c8eb02de08dcf1680a1a81aebd9519daed6023", size = 20940568, upload-time = "2025-12-30T16:12:27.533Z" }, - { url = "https://files.pythonhosted.org/packages/18/d5/6187ffb7e1d24df34defe2718db8c4c3c08f153d3e7da22c250134b79cd1/uv-0.9.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82e438595a609cbe4e45c413a54bd5756d37c8c39108ce7b2799aff15f7d3337", size = 22085077, upload-time = "2025-12-30T16:12:10.153Z" }, - { url = "https://files.pythonhosted.org/packages/ee/fa/8e211167d0690d9f15a08da610a0383d2f43a6c838890878e14948472284/uv-0.9.21-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc1c06e1e5df423e1517e350ea2c9d85ecefd0919188a0a9f19bd239bbbdeeaf", size = 20862893, upload-time = "2025-12-30T16:12:49.87Z" }, - { url = "https://files.pythonhosted.org/packages/33/b2/9d24d84cb9a1a6a5ea98d03a29abf800d87e5710d25e53896dc73aeb63a5/uv-0.9.21-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9ef3d2a213c7720f4dae336e5123fe88427200d7523c78091c4ab7f849c3f13f", size = 21428397, upload-time = "2025-12-30T16:12:07.483Z" }, - { url = "https://files.pythonhosted.org/packages/4f/40/1e8e4c2e1308432c708eaa66dccdb83d2ee6120ea2b7d65e04fc06f48ff8/uv-0.9.21-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8da20914d92ba4cc35f071414d3da7365294fc0b7114da8ac2ab3a86c695096f", size = 22450537, upload-time = "2025-12-30T16:12:33.36Z" }, - { url = "https://files.pythonhosted.org/packages/18/b8/99c4731d001f512e844dfdc740db2bf2fea56d538749b639d21f5117a74a/uv-0.9.21-py3-none-win32.whl", hash = "sha256:e716e23bc0ec8cbb0811f99e653745e0cf15223e7ba5d8857d46be5b40b3045b", size = 20032654, upload-time = "2025-12-30T16:12:36.007Z" }, - { url = "https://files.pythonhosted.org/packages/29/6b/da441bf335f5e1c0c100b7dfb9702b6fed367ba703e543037bf1e70bf8c3/uv-0.9.21-py3-none-win_amd64.whl", hash = "sha256:64a7bb0e4e6a4c2d98c2d55f42aead7c2df0ceb17d5911d1a42b76228cab4525", size = 22206744, upload-time = "2025-12-30T16:12:38.953Z" }, - { url = "https://files.pythonhosted.org/packages/98/02/afbed8309fe07aaa9fa58a98941cebffbcd300fe70499a02a6806d93517b/uv-0.9.21-py3-none-win_arm64.whl", hash = "sha256:6c13c40966812f6bd6ecb6546e5d3e27e7fe9cefa07018f074f51d703cb29e1c", size = 20591604, upload-time = "2025-12-30T16:12:44.634Z" }, +version = "0.11.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/8e/ec34c19d0f254fcbcc5c1ce8c7f06e47e0f69a7e1a0269c1d59cb0b0f279/uv-0.11.17.tar.gz", hash = "sha256:1d1be74deec997db1dda05a7e67541c904d65cbfd72e455d3c0a2a1e4bf2cddf", size = 4203607, upload-time = "2026-05-28T20:39:47.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/2e/e6d42f9d39009eee976f1e5dfd31d3d1943e6e593ad7b191cf11e9744a36/uv-0.11.17-py3-none-linux_armv6l.whl", hash = "sha256:8426bfe315564d414cbc5ba5467595dc6348965e19acec742914f47da3ff269f", size = 23551216, upload-time = "2026-05-28T20:39:05.395Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ee/d72bcc60f3585653a4b768425854d737d98d65c1765547d25c2999547ea9/uv-0.11.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6d1a033cc68cabb4141d6c1e3b66ffc6e970b98ba42e210f33270251e0bd8697", size = 22997377, upload-time = "2026-05-28T20:39:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/58/34/1bc69798d9ae998fbc42c61b02883f2ba00d04bdd858e589604d01846287/uv-0.11.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:58c07ffc272c847d29cd98ca5082fa4304a645f87c718ec900e3cca9026bd096", size = 21630197, upload-time = "2026-05-28T20:39:28.935Z" }, + { url = "https://files.pythonhosted.org/packages/6b/93/1be48ec6a8933d9a77d0ce5240ed63f68869f68517ccf5d62268ed03f3e8/uv-0.11.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:036d6e2940afe8b79637530b01b9241d8cfd174b07f1179a1ebbd42409c38ca3", size = 23414940, upload-time = "2026-05-28T20:39:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/00/31/b7488ff49d80090ea9d05d67a4d381a1b4479502e9853e654caa1c1c678e/uv-0.11.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:283186700c3e65a4644a73a917232da7d3e4a94d25ea0377a44f5b263fa49577", size = 23096330, upload-time = "2026-05-28T20:39:01.284Z" }, + { url = "https://files.pythonhosted.org/packages/fe/95/42b6137c5de06278d229c7eef2f314df2a738cd799795bbb44dace21bd6e/uv-0.11.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2e44dfbfc7778d0d90edc6738f237c91e5e37e4e3cfe94c8a312cec56a41485", size = 23101906, upload-time = "2026-05-28T20:39:17.149Z" }, + { url = "https://files.pythonhosted.org/packages/17/7c/0ca03b2d19965db6d5dfe0c8cf96a3d0b424503c8cbc3cd2ffdc5869a15d/uv-0.11.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a817eeb3026f27a53d3f4b7855a5105f6787dd192140e201eda4d2b9a11b72e", size = 24444409, upload-time = "2026-05-28T20:39:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fb/179f55a3b19d47c30ec1f41b9b964da74dfa7053ff310a70a9c4d8cb998d/uv-0.11.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf8f5ad959583dcd2c4ae445c754a97c05700246ff89259f3fd285c9c20f4c00", size = 25540153, upload-time = "2026-05-28T20:39:09.535Z" }, + { url = "https://files.pythonhosted.org/packages/f7/29/592f42012765c43ae45c112110e214bca7b0cfc08c4c1b52e1dfa47dedd5/uv-0.11.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce16892a45134d20165c1ceababe06f3e9ce6a58902db1eff812c8c93626823f", size = 24665906, upload-time = "2026-05-28T20:39:41.254Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/b75808766f895248553c6370968509cd4f726e6943e310a8f7a171036ad0/uv-0.11.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da839e5a491c9a701d7d327a199cafc76ac27a03ac84fd2a8d4bf32c3af2448", size = 24863325, upload-time = "2026-05-28T20:39:51.006Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6a/6f27ee69e97f480104bb8ec335f04c2a12add98edfcc4844a68e9538b6e2/uv-0.11.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ec004b3c9bf9cb7756067ad1bd0bf64eb843e6fa2edbfbb3135ee152c14cea91", size = 23521674, upload-time = "2026-05-28T20:38:55.869Z" }, + { url = "https://files.pythonhosted.org/packages/df/11/1344aca7c710f794750f74de0e552a54ab24193ecc01fa3b3ae22ff822a1/uv-0.11.17-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:659227cac719b618cc91e02be9e274ad5bd72d74fa278123e6373537e9f28216", size = 24224725, upload-time = "2026-05-28T20:39:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/7b11550c1453ea13b81e549c83523e6ab6ed3231d09b2fd6b9eb19acceaf/uv-0.11.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e301d844eed9401f0f0351de12c55f1306ca05372acb0f28d35717c8ba663a22", size = 24301643, upload-time = "2026-05-28T20:39:45.183Z" }, + { url = "https://files.pythonhosted.org/packages/1a/36/8f683bc60547b8f93d0e752a8574d13fad776999cb978482b360c053ca22/uv-0.11.17-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f0bf483c0d9fa14283992d56061b498b9d3d4adebd285af8744dc33f64dadfba", size = 23786049, upload-time = "2026-05-28T20:39:20.999Z" }, + { url = "https://files.pythonhosted.org/packages/10/dc/7a495db39c2970de4fa375c337dbd617b16780911f88f0511f8fe7f6747c/uv-0.11.17-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:2ccd5487a4a192bc832ea04c867a26883757db8fdfe88bed85d8129c82f9e505", size = 25049786, upload-time = "2026-05-28T20:40:03.292Z" }, + { url = "https://files.pythonhosted.org/packages/37/dd/74eff72d749eaf7e19f489878e21a368a7fef58d26ea0c63ec044ecd78b1/uv-0.11.17-py3-none-win32.whl", hash = "sha256:12b701fa32c5be3691759a73956e4462f30fa7b0dfa52ec66cb305bbb6ea4129", size = 22479213, upload-time = "2026-05-28T20:39:13.316Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/8af4a92b99a8a4823297c26df727fe957267e03e1196e3caa803c3f6ccb2/uv-0.11.17-py3-none-win_amd64.whl", hash = "sha256:44ec1fe3af839f87370dcf0400c0cab917cc1ce697d563e860fc7d9ed72655e7", size = 25083161, upload-time = "2026-05-28T20:40:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/00/76/a689077832d585d29d87f9cd0d65eca1af58abd29a4eab004d0a8a858b9c/uv-0.11.17-py3-none-win_arm64.whl", hash = "sha256:37c915bfcf86f99c1c5be7c9ed21e0d80624067ba47bc8916a3cb0530bc94d27", size = 23544936, upload-time = "2026-05-28T20:39:37.137Z" }, ] [[package]] name = "virtualenv" -version = "20.35.4" +version = "20.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -1544,9 +1554,9 @@ dependencies = [ { name = "platformdirs", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]]