diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fde16d6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @canonical/data-postgresql diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1a3f378 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,47 @@ +--- +name: Bug report +about: File a bug report +labels: bug + +--- + + + +## Steps to reproduce + +1. + +## Expected behavior + + +## Actual behavior + + + +## Versions + + +Operating system: + + +Juju CLI: + + +Juju agent: + + +Charm revision: + + +LXD: + +## Log output + + +Juju debug log: + + + + +## Additional context + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9a15830 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## Issue + +## Solution + +## Checklist +- [ ] I have added or updated any relevant documentation. +- [ ] I have cleaned any remaining cloud resources from my accounts. diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..9ef36ac --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,8 @@ +changelog: + categories: + - title: Features + labels: + - enhancement + - title: Bug fixes + labels: + - bug diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..593aed7 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,15 @@ +{ + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + extends: [ + 'github>canonical/data-platform//renovate_presets/charm.json5', + ], + reviewers: [ + 'team:data-postgresql', + ], + baseBranchPatterns: [ + '/^*\\/edge$/', + ], + packageRules: [ + ], + customManagers: [], +} diff --git a/.github/workflows/approve_renovate_pr.yaml b/.github/workflows/approve_renovate_pr.yaml new file mode 100644 index 0000000..4b9e2f8 --- /dev/null +++ b/.github/workflows/approve_renovate_pr.yaml @@ -0,0 +1,15 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. +name: Approve Renovate pull request + +on: + pull_request: + types: + - opened + +jobs: + approve-pr: + name: Approve Renovate pull request + uses: canonical/data-platform-workflows/.github/workflows/approve_renovate_pr.yaml@v49.0.1 + permissions: + pull-requests: write # Needed to approve PR diff --git a/.github/workflows/check_pr.yaml b/.github/workflows/check_pr.yaml new file mode 100644 index 0000000..48bb202 --- /dev/null +++ b/.github/workflows/check_pr.yaml @@ -0,0 +1,20 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. +name: Check pull request + +on: + pull_request: + types: + - opened + - labeled + - unlabeled + - edited + branches: + - main + - '*/edge' + +jobs: + check-pr: + name: Check pull request + uses: canonical/data-platform-workflows/.github/workflows/check_charm_pr.yaml@v49.0.1 + permissions: {} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..5a80a38 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,89 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +name: Tests + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths-ignore: + - '.gitignore' + - '.jujuignore' + - 'LICENSE' + - '**.md' + - .github/renovate.json5 + - 'docs/**' + - 'terraform/**' + # Triggered on schedule by .github/workflows/scheduled_ci_*.yaml on default branch + workflow_dispatch: + # Triggered on push by .github/workflows/release.yaml + workflow_call: + outputs: + artifact-prefix: + description: build_charm.yaml `artifact-prefix` output + value: ${{ jobs.build.outputs.artifact-prefix }} + +jobs: + lint-workflows: + name: Lint .github/workflows/ + uses: canonical/data-platform-workflows/.github/workflows/lint_workflows.yaml@v49.0.1 + permissions: + contents: read + + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install tox & poetry + run: | + pipx install tox + pipx install poetry + - name: tox run -e lint + run: tox run -e lint + permissions: {} + + unit-test: + name: Unit test charm + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install tox & poetry + run: | + pipx install tox + pipx install poetry + - name: Run tests + run: tox run -e unit + permissions: {} + + build: + name: Build charm + uses: canonical/data-platform-workflows/.github/workflows/build_charm.yaml@v49.0.1 + with: + cache: false + permissions: + actions: read # Needed for GitHub API call to get workflow version + contents: read + + integration-test: + name: Integration test charm + needs: + - lint-workflows + - lint + - unit-test + - build + uses: ./.github/workflows/integration_test.yaml + with: + artifact-prefix: ${{ needs.build.outputs.artifact-prefix }} + permissions: + contents: write # Needed for Allure Report diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml new file mode 100644 index 0000000..82c948c --- /dev/null +++ b/.github/workflows/integration_test.yaml @@ -0,0 +1,399 @@ +on: + workflow_call: + inputs: + artifact-prefix: + description: | + Prefix for charm package GitHub artifact(s) + + Use canonical/data-platform-workflows build_charm.yaml to build the charm(s) + required: true + type: string + +jobs: + collect-integration-tests: + name: Collect integration test spread jobs + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up environment + run: | + sudo snap install go --classic + go install github.com/canonical/spread/cmd/spread@latest + pipx install tox poetry + - name: Collect spread jobs + id: collect-jobs + shell: python + run: | + import json + import pathlib + import os + import subprocess + + spread_jobs = ( + subprocess.run( + [pathlib.Path.home() / "go/bin/spread", "-list", "github-ci"], + capture_output=True, + check=True, + text=True, + ) + .stdout.strip() + .split("\n") + ) + jobs = [] + for job in spread_jobs: + # Example `job`: "github-ci:ubuntu-24.04:tests/spread/test_charm.py:juju36" + _, runner, task, variant = job.split(":") + # Example: "test_charm.py" + task = task.removeprefix("tests/spread/") + if runner.endswith("-arm"): + architecture = "arm64" + else: + architecture = "amd64" + # Example: "test_charm.py:juju36 | amd64" + name = f"{task}:{variant} | {architecture}" + # ":" character not valid in GitHub Actions artifact + name_in_artifact = f"{task}-{variant}-{architecture}" + jobs.append({ + "spread_job": job, + "name": name, + "name_in_artifact": name_in_artifact, + "runner": runner, + }) + output = f"jobs={json.dumps(jobs)}" + print(output) + with open(os.environ["GITHUB_OUTPUT"], "a") as file: + file.write(output) + - name: Generate Allure default test results + if: ${{ github.event_name == 'workflow_dispatch' && github.run_attempt == '1' }} + run: tox run -e integration -- tests/integration --allure-default-dir=allure-default-results + - name: Upload Allure default results + # Default test results in case the integration tests time out or runner set up fails + # (So that Allure report will show "unknown"/"failed" test result, instead of omitting the test) + if: ${{ github.event_name == 'workflow_dispatch' && github.run_attempt == '1' }} + uses: actions/upload-artifact@v7 + with: + name: allure-default-results-integration-test + path: allure-default-results/ + if-no-files-found: error + outputs: + jobs: ${{ steps.collect-jobs.outputs.jobs }} + permissions: {} + + integration-test: + strategy: + fail-fast: false + matrix: + job: ${{ fromJSON(needs.collect-integration-tests.outputs.jobs) }} + name: ${{ matrix.job.name }} + needs: + - collect-integration-tests + runs-on: ${{ matrix.job.runner }} + timeout-minutes: 216 # Sum of steps `timeout-minutes` + 5 + environment: + name: integration-test + deployment: false + steps: + - name: Checkout + timeout-minutes: 3 + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up environment + timeout-minutes: 5 + run: sudo snap install charmcraft --classic + # TODO: remove when https://github.com/canonical/charmcraft/issues/2105 and + # https://github.com/canonical/charmcraft/issues/2130 fixed + - run: | + sudo snap install go --classic + go install github.com/canonical/spread/cmd/spread@latest + - name: Download packed charm(s) + timeout-minutes: 5 + uses: actions/download-artifact@v8 + with: + pattern: ${{ inputs.artifact-prefix }}-* + merge-multiple: true + - name: Run spread job + timeout-minutes: 180 + id: spread + # TODO: replace with `charmcraft test` when + # https://github.com/canonical/charmcraft/issues/2105 and + # https://github.com/canonical/charmcraft/issues/2130 fixed + run: ~/go/bin/spread -vv -artifacts=artifacts "${VAR_SPREAD_JOB}" + env: + VAR_SPREAD_JOB: ${{ matrix.job.spread_job }} + - name: Check spread name for Allure collection + id: check-spread-name + if: ${{ (success() || (failure() && steps.spread.outcome == 'failure')) && github.event_name == 'workflow_dispatch' && github.run_attempt == '1' }} + run: | + if [[ '${{ matrix.job.spread_job }}' =~ ^github-ci:ubuntu-[0-9][0-9].04:[a-z_\/\.0-9]+:juju36$ ]]; then + echo "match=true" >> "$GITHUB_OUTPUT" + fi + - name: Upload Allure results + timeout-minutes: 3 + # Only upload results from one spread system & one spread variant + # Allure can only process one result per pytest test ID. If parameterization is done via + # spread instead of pytest, there will be overlapping pytest test IDs. + if: ${{ (success() || (failure() && steps.spread.outcome == 'failure')) && steps.check-spread-name.outputs.match == 'true' && github.event_name == 'workflow_dispatch' && github.run_attempt == '1' }} + uses: actions/upload-artifact@v7 + with: + name: allure-results-integration-test-${{ matrix.job.name_in_artifact }} + path: artifacts/${{ matrix.job.spread_job }}/allure-results/ + if-no-files-found: error + - timeout-minutes: 1 + if: ${{ success() || (failure() && steps.spread.outcome == 'failure') }} + run: snap list + - name: lxc image list + timeout-minutes: 1 + if: ${{ success() || (failure() && steps.spread.outcome == 'failure') }} + # sudo needed since spread runs scripts as root + run: sudo lxc image list + - name: Select model + timeout-minutes: 1 + # `!contains(matrix.job.spread_job, 'juju29')` workaround for juju 2 error: + # "ERROR cannot acquire lock file to read controller concierge-microk8s: unable to open + # /tmp/juju-store-lock-3635383939333230: permission denied" + # Unable to workaround error with `sudo rm /tmp/juju-*` + if: ${{ !contains(matrix.job.spread_job, 'juju29') && (success() || (failure() && steps.spread.outcome == 'failure')) }} + id: juju-switch + run: | + # sudo needed since spread runs scripts as root + # "testing" is default model created by concierge + sudo juju switch testing + mkdir ~/logs/ + - name: juju status + timeout-minutes: 1 + if: ${{ !contains(matrix.job.spread_job, 'juju29') && (success() || (failure() && steps.spread.outcome == 'failure')) }} + run: sudo juju status --color --relations --storage | tee ~/logs/juju-status.txt + - name: juju debug-log + timeout-minutes: 3 + if: ${{ !contains(matrix.job.spread_job, 'juju29') && (success() || (failure() && steps.spread.outcome == 'failure')) }} + run: sudo juju debug-log --color --replay --no-tail | tee ~/logs/juju-debug-log.txt + - name: jhack tail + timeout-minutes: 3 + if: ${{ !contains(matrix.job.spread_job, 'juju29') && (success() || (failure() && steps.spread.outcome == 'failure')) }} + run: sudo jhack tail --printer raw --replay --no-watch | tee ~/logs/jhack-tail.txt + - name: Upload logs + timeout-minutes: 5 + if: ${{ !contains(matrix.job.spread_job, 'juju29') && (success() || (failure() && steps.spread.outcome == 'failure')) }} + uses: actions/upload-artifact@v7 + with: + name: logs-integration-test-${{ matrix.job.name_in_artifact }} + path: ~/logs/ + if-no-files-found: error + - name: Disk usage + timeout-minutes: 1 + if: ${{ success() || (failure() && steps.spread.outcome == 'failure') }} + run: df --human-readable + permissions: {} + + allure-report: + name: Publish Allure report + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && github.run_attempt == '1' }} + concurrency: + group: github-pages + cancel-in-progress: false + needs: + - integration-test + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Parse branch name + id: branch + shell: python + run: | + import os + + if not os.environ["VAR_REF"].startswith("refs/heads/"): + raise Exception( + "Allure Report generation assumes `workflow_dispatch` is triggered on a branch" + ) + output = f"branch={os.environ['VAR_REF_NAME'].replace('/', '_')}" + print(output) + with open(os.environ["GITHUB_OUTPUT"], "a") as file: + file.write(output) + env: + VAR_REF: ${{ github.ref }} + VAR_REF_NAME: ${{ github.ref_name }} + - name: Download Allure + # Following instructions from https://allurereport.org/docs/install-for-linux/#install-from-a-deb-package + run: gh release download --repo allure-framework/allure2 --pattern 'allure_*.deb' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install Allure + run: | + sudo apt-get update + sudo apt-get install ./allure_*.deb -y + # For first run, manually create branch with no history + # (e.g. + # git checkout --orphan gh-pages-beta + # git rm -rf . + # touch .nojekyll + # git add .nojekyll + # git commit -m "Initial commit" + # git push origin gh-pages-beta + # ) + - name: Checkout GitHub pages branch + uses: actions/checkout@v6 + with: + persist-credentials: true + ref: gh-pages-beta + path: repo/ + - name: Download default test results + # Default test results in case the integration tests time out or runner set up fails + # (So that Allure report will show "unknown"/"failed" test result, instead of omitting the test) + uses: actions/download-artifact@v8 + with: + path: allure-default-results/ + name: allure-default-results-integration-test + - name: Download test results + uses: actions/download-artifact@v8 + with: + path: allure-results/ + pattern: allure-results-integration-test-* + merge-multiple: true + - name: Combine Allure default results & actual results + # For every test: if actual result available, use that. Otherwise, use default result + # So that, if actual result not available, Allure report will show "unknown"/"failed" test result + # instead of omitting the test + shell: python + run: | + import dataclasses + import json + import pathlib + + + @dataclasses.dataclass(frozen=True) + class Result: + test_case_id: str + path: pathlib.Path + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + return self.test_case_id == other.test_case_id + + + actual_results = pathlib.Path("allure-results") + default_results = pathlib.Path("allure-default-results") + + results: dict[pathlib.Path, set[Result]] = { + actual_results: set(), + default_results: set(), + } + for directory, results_ in results.items(): + for path in directory.glob("*-result.json"): + with path.open("r") as file: + id_ = json.load(file)["testCaseId"] + results_.add(Result(id_, path)) + + actual_results.mkdir(exist_ok=True) + + missing_results = results[default_results] - results[actual_results] + for default_result in missing_results: + # Move to `actual_results` directory + default_result.path.rename(actual_results / default_result.path.name) + - name: Load test report history + run: | + mkdir -p repo/"${VAR_BRANCH}"/ + if [[ -d repo/"${VAR_BRANCH}"/_latest/history/ ]] + then + echo 'Loading history' + cp -r repo/"${VAR_BRANCH}"/_latest/history/ allure-results/ + fi + env: + VAR_BRANCH: ${{ steps.branch.outputs.branch }} + - name: Create executor.json + shell: python + run: | + # Reverse engineered from https://github.com/simple-elf/allure-report-action/blob/eca283b643d577c69b8e4f048dd6cd8eb8457cfd/entrypoint.sh + import json + import os + + DATA = { + "name": "GitHub Actions", + "type": "github", + "buildOrder": os.environ["VAR_RUN_NUMBER"], # TODO future improvement: use run ID + "buildName": f"Run {os.environ['VAR_RUN_ID']}", + "buildUrl": f"https://github.com/{os.environ['VAR_REPOSITORY']}/actions/runs/{os.environ['VAR_RUN_ID']}", + "reportUrl": f"../{os.environ['VAR_RUN_NUMBER']}/", + } + with open("allure-results/executor.json", "w") as file: + json.dump(DATA, file) + env: + VAR_RUN_NUMBER: ${{ github.run_number }} + VAR_RUN_ID: ${{ github.run_id }} + VAR_REPOSITORY: ${{ github.repository }} + - name: Generate Allure report + run: allure generate + - name: Create top-level index.html + shell: python + run: | + import os + + DATA = """ + + + + + """ + with open("repo/index.html", "w") as file: + file.write(DATA) + env: + VAR_REPOSITORY: ${{ github.repository }} + - name: Create branch index.html + shell: python + run: | + import os + + DATA = f""" + + + + """ + with open(f"repo/{os.environ['VAR_BRANCH']}/index.html", "w") as file: + file.write(DATA) + env: + VAR_RUN_NUMBER: ${{ github.run_number }} + VAR_BRANCH: ${{ steps.branch.outputs.branch }} + - name: Update GitHub pages branch + working-directory: repo/${{ steps.branch.outputs.branch }}/ + # TODO future improvement: commit message + run: | + mkdir "${VAR_RUN_NUMBER}" + rm -f _latest + ln -s "${VAR_RUN_NUMBER}" _latest + cp -r ../../allure-report/. _latest/ + git add .. + git config user.name "GitHub Actions" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Allure report ${VAR_RUN_NUMBER}" + # Uses token set in checkout step + git push origin gh-pages-beta + env: + VAR_RUN_NUMBER: ${{ github.run_number }} + permissions: + contents: write # Needed for Allure Report diff --git a/.github/workflows/lib-check.yaml b/.github/workflows/lib-check.yaml new file mode 100644 index 0000000..6fcfdce --- /dev/null +++ b/.github/workflows/lib-check.yaml @@ -0,0 +1,37 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +name: Check libs + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths-ignore: + - '.gitignore' + - '.jujuignore' + - 'LICENSE' + - '**.md' + - 'renovate.json' + - 'docs/**' + +jobs: + lib-check: + name: Check libraries + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.event.pull_request.head.repo.full_name == 'canonical/postgresql-watcher-operator' }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + fetch-depth: 0 + - name: Check libs + uses: canonical/charming-actions/check-libraries@2.7.0 + with: + credentials: '' # `charmcraft fetch-lib` no longer requires authentication; but action still requires input + github-token: "${{ secrets.GITHUB_TOKEN }}" + permissions: + pull-requests: write # Add label to prs diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml new file mode 100644 index 0000000..94d4de1 --- /dev/null +++ b/.github/workflows/promote.yaml @@ -0,0 +1,37 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. +name: Promote charm + +on: + workflow_dispatch: + inputs: + from-risk: + description: Promote from this Charmhub risk + required: true + type: choice + options: + - edge + - beta + - candidate + to-risk: + description: Promote to this Charmhub risk + required: true + type: choice + options: + - beta + - candidate + - stable + +jobs: + promote: + name: Promote charm + uses: canonical/data-platform-workflows/.github/workflows/_promote_charms_legacy_2.yaml@v49.0.1 + with: + track: '16' + from-risk: ${{ inputs.from-risk }} + to-risk: ${{ inputs.to-risk }} + secrets: + charmhub-token: ${{ secrets.CHARMHUB_TOKEN }} + permissions: + actions: read # Needed for GitHub API call to get workflow version + contents: write # Needed to edit GitHub releases diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..60e9d2c --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,50 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +name: Release to Charmhub edge + +on: + push: + branches: + - '*/edge' + paths-ignore: + - 'tests/**' + - 'docs/**' + - .github/renovate.json5 + - pyproject.toml + - '.github/workflows/ci.yaml' + - '.github/workflows/lib-check.yaml' + - '.github/workflows/sync_docs.yaml' + +jobs: + tag: + name: Create charm refresh compatibility version git tag + uses: canonical/data-platform-workflows/.github/workflows/tag_charm_edge.yaml@v49.0.1 + with: + track: '16' + permissions: + actions: read # Needed for GitHub API call to get workflow version + contents: write # Needed to create git tag + + ci-tests: + name: Tests + needs: + - tag + uses: ./.github/workflows/ci.yaml + permissions: + actions: read # Needed for GitHub API call to get workflow version + contents: write # Needed for Allure Report + + release: + name: Release charm + needs: + - tag + - ci-tests + uses: canonical/data-platform-workflows/.github/workflows/release_charm_edge.yaml@v49.0.1 + with: + track: 16 + artifact-prefix: ${{ needs.ci-tests.outputs.artifact-prefix }} + secrets: + charmhub-token: ${{ secrets.CHARMHUB_TOKEN }} + permissions: + actions: read # Needed for GitHub API call to get workflow version + contents: write # Needed to create git tags diff --git a/.github/zizmor.yaml b/.github/zizmor.yaml new file mode 100644 index 0000000..8fe8068 --- /dev/null +++ b/.github/zizmor.yaml @@ -0,0 +1,31 @@ +rules: + # Allowlist trusted actions + forbidden-uses: + config: + # Actions in this list have remote code execution in our workflows. Even if the workflow has + # limited permissions, those can be escaped. See https://github.com/AdnaneKhan/Cacheract and + # "But Wait, There’s More" section of + # https://www.praetorian.com/blog/codeqleaked-public-secrets-exposure-leads-to-supply-chain-attack-on-github-codeql/ + # In general, this list should be limited to organizations that we trust to have a baseline + # level of operational security. Avoid adding actions that could be replaced with a few lines + # of bash. + allow: # Get approval from your manager before adding actions to this list. + - canonical/* + - actions/* + - docker/login-action + - tiobe/tics-github-action + # Pinning actions to a commit SHA has a security tradeoff—pinned actions cannot be later + # compromised, but they also cannot be security patched. + # Above (in `forbidden-uses`), we restrict actions usage to organizations that we trust to have + # a baseline level of operational security. + # Our actions usage involves several long-term support branches and reusable workflows (which + # themselves use actions). For our threat model, we believe it is safer to limit actions usage to + # organizations we trust and immediately receive security patching across our many branches than + # it is to pin actions to a commit SHA. + unpinned-uses: + config: + policies: + canonical/*: ref-pin + actions/*: ref-pin + docker/login-action: ref-pin + tiobe/tics-github-action: ref-pin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7575a83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +coverage.xml +/requirements.txt +requirements-last-build.txt +.last_refresh_unit_status.json + +# PyCharm project folder. +.idea/ + +# LXD profile used for deployment on local development environment. +lxd-profile.yaml + +# Terraform runtime +terraform/.terraform.lock.hcl +terraform/.terraform/ +terraform/terraform.tfstate +terraform/terraform.tfstate.backup diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ddba86a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security policy + +## What qualifies as a security issue + +Credentials leakage, outdated dependencies with known vulnerabilities, and +other issues that could lead to unprivileged or unauthorized access to the +database or the system. + +## Reporting a vulnerability + +The easiest way to report a security issue is through +[GitHub](https://github.com/canonical/postgresql-operator/security/advisories/new). See +[Privately reporting a security +vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) +for instructions. + +The repository admins will be notified of the issue and will work with you +to determine whether the issue qualifies as a security issue and, if so, in +which component. We will then handle figuring out a fix, getting a CVE +assigned and coordinating the release of the fix. + +The [Ubuntu Security disclosure and embargo +policy](https://ubuntu.com/security/disclosure-policy) contains more +information about what you can expect when you contact us, and what we +expect from you. diff --git a/actions.yaml b/actions.yaml new file mode 100644 index 0000000..04842f2 --- /dev/null +++ b/actions.yaml @@ -0,0 +1,58 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +pre-refresh-check: + description: Check if charm is ready to refresh + additionalProperties: false +force-refresh-start: + description: | + Potential of data loss and downtime + + Force refresh of first unit + + Must run with at least one of the parameters `=false` + params: + check-compatibility: + type: boolean + default: true + description: | + Potential of data loss and downtime + + If `false`, force refresh if new version of PostgreSQL and/or charm is not compatible with previous version + run-pre-refresh-checks: + type: boolean + default: true + description: | + Potential of data loss and downtime + + If `false`, force refresh if app is unhealthy or not ready to refresh (and unit status shows "Pre-refresh check failed") + check-workload-container: + type: boolean + default: true + description: | + Potential of data loss and downtime during and after refresh + + If `false`, allow refresh to PostgreSQL container version that has not been validated to work with the charm revision + additionalProperties: false +resume-refresh: + description: | + Refresh next unit(s) (after you have manually verified that refreshed units are healthy) + + If the `pause-after-unit-refresh` config is set to `all`, this action will refresh the next unit. + + If `pause-after-unit-refresh` is set to `first`, this action will refresh all remaining units. + Exception: if automatic health checks fail after a unit has refreshed, the refresh will pause. + + If `pause-after-unit-refresh` is set to `none`, this action will have no effect unless it is called with `check-health-of-refreshed-units` as `false`. + params: + check-health-of-refreshed-units: + type: boolean + default: true + description: | + Potential of data loss and downtime + + If `false`, force refresh (of next unit) if 1 or more refreshed units are unhealthy + + Warning: if first unit to refresh is unhealthy, consider running `force-refresh-start` action on that unit instead of using this parameter. + If first unit to refresh is unhealthy because compatibility checks, pre-refresh checks, or workload container checks are failing, this parameter is more destructive than the `force-refresh-start` action. + additionalProperties: false diff --git a/charmcraft.yaml b/charmcraft.yaml new file mode 100644 index 0000000..662901c --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,113 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +type: charm +platforms: + ubuntu@24.04:amd64: + ubuntu@24.04:arm64: +# Files implicitly created by charmcraft without a part: +# - dispatch (https://github.com/canonical/charmcraft/pull/1898) +# - manifest.yaml +# (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/services/package.py#L259) +# Files implicitly copied/"staged" by charmcraft without a part: +# - actions.yaml, config.yaml, metadata.yaml +# (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/services/package.py#L290-L293 +# https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/services/package.py#L156-L157) +parts: + # "poetry-deps" part name is a magic constant + # https://github.com/canonical/craft-parts/pull/901 + poetry-deps: + plugin: nil + build-packages: + - curl + override-build: | + # Use environment variable instead of `--break-system-packages` to avoid failing on older + # versions of pip that do not recognize `--break-system-packages` + # `--user` needed (in addition to `--break-system-packages`) for Ubuntu >=24.04 + PIP_BREAK_SYSTEM_PACKAGES=true python3 -m pip install --user --upgrade pip==26.1 # renovate: charmcraft-pip-latest + + # Use uv to install poetry so that a newer version of Python can be installed if needed by poetry + curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.8/uv-installer.sh | sh # renovate: charmcraft-uv-latest + # poetry 2.0.0 requires Python >=3.9 + if ! "$HOME/.local/bin/uv" python find '>=3.9' + then + # Use first Python version that is >=3.9 and available in an Ubuntu LTS + # (to reduce the number of Python versions we use) + "$HOME/.local/bin/uv" python install 3.10.12 # renovate: charmcraft-python-ubuntu-22.04 + fi + "$HOME/.local/bin/uv" tool install --no-python-downloads --python '>=3.9' poetry==2.3.4 --with poetry-plugin-export==1.10.0 # renovate: charmcraft-poetry-latest + + ln -sf "$HOME/.local/bin/poetry" /usr/local/bin/poetry + # "charm-poetry" part name is arbitrary; use for consistency + # Avoid using "charm" part name since that has special meaning to charmcraft + charm-poetry: + # By default, the `poetry` plugin creates/stages these directories: + # - lib, src + # (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/parts/plugins/_poetry.py#L76-L78) + # - venv + # (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/parts/plugins/_poetry.py#L95 + # https://github.com/canonical/craft-parts/blob/afb0d652eb330b6aaad4f40fbd6e5357d358de47/craft_parts/plugins/base.py#L270) + plugin: poetry + source: . + after: + - poetry-deps + poetry-export-extra-args: ['--only', 'main,charm-libs'] + build-packages: + - libffi-dev # Needed to build Python dependencies with Rust from source + - libssl-dev # Needed to build Python dependencies with Rust from source + - pkg-config # Needed to build Python dependencies with Rust from source + - libpq-dev + override-build: | + # Workaround for https://github.com/canonical/charmcraft/issues/2068 + # rustup used to install rustc and cargo, which are needed to build Python dependencies with Rust from source + if [[ "$CRAFT_PLATFORM" == ubuntu@20.04:* || "$CRAFT_PLATFORM" == ubuntu@22.04:* ]] + then + snap install rustup --classic + else + apt-get install rustup -y + fi + + # If Ubuntu version < 24.04, rustup was installed from snap instead of from the Ubuntu + # archive—which means the rustup version could be updated at any time. Print rustup version + # to build log to make changes to the snap's rustup version easier to track + rustup --version + + # rpds-py (Python package) >=0.19.0 requires rustc >=1.76, which is not available in the + # Ubuntu 22.04 archive. Install rustc and cargo using rustup instead of the Ubuntu archive + rustup set profile minimal + rustup default 1.95.0 # renovate: charmcraft-rust-latest + + craftctl default + # Include requirements.txt in *.charm artifact for easier debugging + cp requirements.txt "$CRAFT_PART_INSTALL/requirements.txt" + # "files" part name is arbitrary; use for consistency + files: + plugin: dump + source: . + after: + - poetry-deps # Ensure poetry is installed + build-packages: + - git + override-build: | + # Set `charm_version` in refresh_versions.toml from git tag + # Create venv in `..` so that git working tree is not dirty + python3 -m venv ../refresh-version-venv + source ../refresh-version-venv/bin/activate + poetry install --only build-refresh-version + write-charm-version + + craftctl default + stage: + - LICENSE + - refresh_versions.toml + - templates + libpq: + build-packages: + - libpq-dev + plugin: dump + source: /usr/lib/ + source-type: local + stage: + - lib/ + organize: + "*-linux-gnu/libpq.so*": lib/ diff --git a/concierge.yaml b/concierge.yaml new file mode 100644 index 0000000..15a78cc --- /dev/null +++ b/concierge.yaml @@ -0,0 +1,13 @@ +juju: + model-defaults: + logging-config: =INFO; unit=DEBUG +providers: + lxd: + enable: true + bootstrap: true +host: + snaps: + jhack: + channel: latest/edge + connections: + - jhack:dot-local-share-juju snapd diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..abee155 --- /dev/null +++ b/config.yaml @@ -0,0 +1,18 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +options: + pause-after-unit-refresh: + description: | + Wait for manual confirmation to resume refresh after these units refresh + Allowed values: "all", "first", "none" + type: string + default: first + profile: + description: | + Profile representing the scope of deployment, and used to tune resource allocation. + Allowed values are: “production” and “testing”. + Production will tune postgresql for maximum performance while testing will tune for + minimal running performance. + type: string + default: "production" diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..cf069ab --- /dev/null +++ b/icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/lib/charms/data_platform_libs/v1/data_models.py b/lib/charms/data_platform_libs/v1/data_models.py new file mode 100644 index 0000000..9d700db --- /dev/null +++ b/lib/charms/data_platform_libs/v1/data_models.py @@ -0,0 +1,361 @@ +# Copyright 2024 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +r"""Library to provide simple API for promoting typed, validated and structured dataclass in charms. + +Dict-like data structure are often used in charms. They are used for config, action parameters +and databag. This library aims at providing simple API for using pydantic BaseModel-derived class +in charms, in order to enhance: +* Validation, by embedding custom business logic to validate single parameters or even have + validators that acts across different fields +* Parsing, by loading data into pydantic object we can both allow for other types (e.g. float) to + be used in configuration/parameters as well as specify even nested complex objects for databags +* Static typing checks, by moving from dict-like object to classes with typed-annotated properties, + that can be statically checked using mypy to ensure that the code is correct. + +Pydantic models can be used on: + +* Charm Configuration (as defined in config.yaml) +* Actions parameters (as defined in actions.yaml) +* Application/Unit Databag Information (thus making it more structured and encoded) + + +## Creating models + +Any data-structure can be modeled using dataclasses instead of dict-like objects (e.g. storing +config, action parameters and databags). Within pydantic, we can define dataclasses that provides +also parsing and validation on standard dataclass implementation: + +```python + +from charms.data_platform_libs.v1.data_models import BaseConfigModel + +class MyConfig(BaseConfigModel): + + my_key: int + + @validator("my_key") + def is_lower_than_100(cls, v: int): + if v > 100: + raise ValueError("Too high") + +``` + +This should allow to collapse both parsing and validation as the dataclass object is parsed and +created: + +```python +dataclass = MyConfig(my_key="1") + +dataclass.my_key # this returns 1 (int) +dataclass["my_key"] # this returns 1 (int) + +dataclass = MyConfig(my_key="102") # this returns a ValueError("Too High") +``` + +## Charm Configuration Model + +Using the class above, we can implement parsing and validation of configuration by simply +extending our charms using the `TypedCharmBase` class, as shown below. + +```python +class MyCharm(TypedCharmBase[MyConfig]): + config_type = MyConfig + + # everywhere in the code you will have config property already parsed and validate + def my_method(self): + self.config: MyConfig +``` + +## Action parameters + +In order to parse action parameters, we can use a decorator to be applied to action event +callbacks, as shown below. + +```python +@validate_params(PullActionModel) +def _pull_site_action( + self, event: ActionEvent, + params: Optional[Union[PullActionModel, ValidationError]] = None +): + if isinstance(params, ValidationError): + # handle errors + else: + # do stuff +``` + +Note that this changes the signature of the callbacks by adding an extra parameter with the parsed +counterpart of the `event.params` dict-like field. If validation fails, we return (not throw!) the +exception, to be handled (or raised) in the callback. + +## Databag + +In order to parse databag fields, we define a decorator to be applied to base relation event +callbacks. + +```python +@parse_relation_data(app_model=AppDataModel, unit_model=UnitDataModel) +def _on_cluster_relation_joined( + self, event: RelationEvent, + app_data: Optional[Union[AppDataModel, ValidationError]] = None, + unit_data: Optional[Union[UnitDataModel, ValidationError]] = None +) -> None: + ... +``` + +The parameters `app_data` and `unit_data` refers to the databag of the entity which fired the +RelationEvent. + +When we want to access to a relation databag outsides of an action, it can be useful also to +compact multiple databags into a single object (if there are no conflicting fields), e.g. + +```python + +class ProviderDataBag(BaseClass): + provider_key: str + +class RequirerDataBag(BaseClass): + requirer_key: str + +class MergedDataBag(ProviderDataBag, RequirerDataBag): + pass + +merged_data = get_relation_data_as( + MergedDataBag, relation.data[self.app], relation.data[relation.app] +) + +merged_data.requirer_key +merged_data.provider_key + +``` + +The above code can be generalized to other kinds of merged objects, e.g. application and unit, and +it can be extended to multiple sources beyond 2: + +```python +merged_data = get_relation_data_as( + MergedDataBag, relation.data[self.app], relation.data[relation.app], ... +) +``` + +""" + +import json +from functools import reduce, wraps +from typing import Callable, Generic, MutableMapping, Optional, Type, TypeVar, Union + +import pydantic +from ops.charm import ActionEvent, CharmBase, RelationEvent +from ops.model import RelationDataContent +from pydantic import BaseModel, ValidationError + +# The unique Charmhub library identifier, never change it +LIBID = "cb2094c5b07d47e1bf346aaee0fcfcfe" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 0 + +PYDEPS = ["ops>=2.0.0", "pydantic>=2,<3"] + +G = TypeVar("G") +T = TypeVar("T", bound=BaseModel) +AppModel = TypeVar("AppModel", bound=BaseModel) +UnitModel = TypeVar("UnitModel", bound=BaseModel) + +DataBagNativeTypes = ( + int, + str, + float, + Optional[int], + Optional[str], + Optional[float], +) + + +class BaseConfigModel(BaseModel): + """Class to be used for defining the structured configuration options.""" + + def __getitem__(self, x): + """Return the item using the notation instance[key].""" + return getattr(self, x.replace("-", "_")) + + +class TypedCharmBase(CharmBase, Generic[T]): + """Class to be used for extending config-typed charms.""" + + config_type: Type[T] + + @property + def config(self) -> T: + """Return a config instance validated and parsed using the provided pydantic class.""" + translated_keys = {k.replace("-", "_"): v for k, v in self.model.config.items()} + return self.config_type(**translated_keys) + + +def validate_params(cls: Type[T]): + """Return a decorator to allow pydantic parsing of action parameters. + + Args: + cls: Pydantic class representing the model to be used for parsing the content of the + action parameter + """ + + def decorator( + f: Callable[[CharmBase, ActionEvent, Union[T, ValidationError]], G], + ) -> Callable[[CharmBase, ActionEvent], G]: + @wraps(f) + def event_wrapper(self: CharmBase, event: ActionEvent): + try: + params = cls( + **{key.replace("-", "_"): value for key, value in event.params.items()} + ) + except ValidationError as e: + params = e + return f(self, event, params) + + return event_wrapper + + return decorator + + +def write(relation_data: RelationDataContent, model: BaseModel): + """Write the data contained in a domain object to the relation databag. + + Args: + relation_data: pointer to the relation databag + model: instance of pydantic model to be written + """ + for key, value in model.model_dump(exclude_none=False).items(): + if value: + relation_data[key.replace("_", "-")] = ( + str(value) + if any(isinstance(value, _type) for _type in DataBagNativeTypes) + else json.dumps(value) + ) + else: + relation_data[key.replace("_", "-")] = "" + + +def read(relation_data: MutableMapping[str, str], obj: Type[T]) -> T: + """Read data from a relation databag and parse it into a domain object. + + Args: + relation_data: pointer to the relation databag + obj: pydantic class representing the model to be used for parsing + """ + return obj( + **{ + field_name: ( + relation_data[parsed_key] + if field_info.annotation in DataBagNativeTypes + else json.loads(relation_data[parsed_key]) + ) + for field_name, field_info in obj.model_fields.items() + # pyright: ignore[reportGeneralTypeIssues] + if (parsed_key := field_name.replace("_", "-")) in relation_data + if relation_data[parsed_key] + } + ) + + +def parse_relation_data( + app_model: Optional[Type[AppModel]] = None, unit_model: Optional[Type[UnitModel]] = None +): + """Return a decorator to allow pydantic parsing of the app and unit databags. + + Args: + app_model: Pydantic class representing the model to be used for parsing the content of the + app databag. None if no parsing ought to be done. + unit_model: Pydantic class representing the model to be used for parsing the content of the + unit databag. None if no parsing ought to be done. + """ + + def decorator( + f: Callable[ + [ + CharmBase, + RelationEvent, + Optional[Union[AppModel, ValidationError]], + Optional[Union[UnitModel, ValidationError]], + ], + G, + ], + ) -> Callable[[CharmBase, RelationEvent], G]: + @wraps(f) + def event_wrapper(self: CharmBase, event: RelationEvent): + try: + app_data = ( + read(event.relation.data[event.app], app_model) + if app_model is not None and event.app + else None + ) + except pydantic.ValidationError as e: + app_data = e + + try: + unit_data = ( + read(event.relation.data[event.unit], unit_model) + if unit_model is not None and event.unit + else None + ) + except pydantic.ValidationError as e: + unit_data = e + + return f(self, event, app_data, unit_data) + + return event_wrapper + + return decorator + + +class RelationDataModel(BaseModel): + """Base class to be used for creating data models to be used for relation databags.""" + + def write(self, relation_data: RelationDataContent): + """Write data to a relation databag. + + Args: + relation_data: pointer to the relation databag + """ + return write(relation_data, self) + + @classmethod + def read(cls, relation_data: RelationDataContent) -> "RelationDataModel": + """Read data from a relation databag and parse it as an instance of the pydantic class. + + Args: + relation_data: pointer to the relation databag + """ + return read(relation_data, cls) + + +def get_relation_data_as( + model_type: Type[AppModel], + *relation_data: RelationDataContent, +) -> Union[AppModel, ValidationError]: + """Return a merged representation of the provider and requirer databag into a single object. + + Args: + model_type: pydantic class representing the merged databag + relation_data: list of RelationDataContent of provider/requirer/unit sides + """ + try: + app_data = read(reduce(lambda x, y: dict(x) | dict(y), relation_data, {}), model_type) + except pydantic.ValidationError as e: + app_data = e + return app_data diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..278424a --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,46 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +name: postgresql-watcher +display-name: Charmed PostgreSQL VM Watcher +summary: Patroni/Raft watcher for Charmed PostgreSQL VMs +description: | + PostgreSQL is an open-source relational database management system + built to handle a wide range of workloads. This charmed operator deploys + and operates PostgreSQL on virtual machines and bare metal. + + For deployment on Kubernetes, see [Charmed PostgreSQL K8s](https://charmhub.io/postgresql-k8s). + + To learn more about how to deploy and manage this charm, see the + [official documentation](https://canonical-charmed-postgresql.readthedocs-hosted.com/16/). +docs: https://canonical-charmed-postgresql.readthedocs-hosted.com/16/ +source: https://github.com/canonical/postgresql-watcher-operator +issues: https://github.com/canonical/postgresql-watcher-operator/issues +website: + - https://canonical.com/data/postgresql + - https://github.com/canonical/postgresql-watcher-operator + - https://github.com/canonical/postgresql-operator +maintainers: + - Canonical Data Platform +contact: https://matrix.to/#/#charmhub-data-platform:ubuntu.com + +peers: + database-peers: + interface: postgresql_peers + refresh-v-three: + interface: refresh + +requires: + watcher: + interface: postgresql_watcher + optional: true + +assumes: + - juju + - any-of: + - all-of: + - juju >= 3.4.3 + - juju < 3.5 + - all-of: + - juju >= 3.5.1 + - juju < 4 diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..4ce5e7b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,2959 @@ +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. + +[[package]] +name = "allure-pytest" +version = "2.16.0" +description = "Allure pytest integration" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "allure_pytest-2.16.0-py3-none-any.whl", hash = "sha256:e5035a3b1f541b0c2ee566822df6fec20e0628b8536780e5bdfe39060ac0c71b"}, + {file = "allure_pytest-2.16.0.tar.gz", hash = "sha256:3cc883595b1ce4280b0b9a5fdaa0ce3bb5cdbaa1b43c7269d8b82e825e6e107c"}, +] + +[package.dependencies] +allure-python-commons = "2.16.0" +pytest = ">=4.5.0" + +[[package]] +name = "allure-pytest-default-results" +version = "0.1.4" +description = "Generate default \"unknown\" results to show in Allure Report if test case does not run" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "allure_pytest_default_results-0.1.4-py3-none-any.whl", hash = "sha256:b9cfc442e6108552c2cc0c3d818f61f8d718bca219ec60041273aa4ae57f0c04"}, + {file = "allure_pytest_default_results-0.1.4.tar.gz", hash = "sha256:2eeaacf7836105724df549042e13b740b91013b2a53258ac456ea5fd82f3faa0"}, +] + +[package.dependencies] +allure-pytest = ">=2.13.5" +pytest = "*" + +[[package]] +name = "allure-python-commons" +version = "2.16.0" +description = "Contains the API for end users as well as helper functions and classes to build Allure adapters for Python test frameworks" +optional = false +python-versions = ">=3.6" +groups = ["integration"] +files = [ + {file = "allure_python_commons-2.16.0-py3-none-any.whl", hash = "sha256:6d42a500078aca8a2e68823075c1ffc2396987bb268d62b19af82390b205ce88"}, + {file = "allure_python_commons-2.16.0.tar.gz", hash = "sha256:ecdc92bafea074bab96b5f2c4eb3100825340188f5aece608ae80eced709b36f"}, +] + +[package.dependencies] +attrs = ">=16.0.0" +pluggy = ">=0.4.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["charm-libs"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.13.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + +[[package]] +name = "asttokens" +version = "3.0.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"}, + {file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"}, +] + +[package.extras] +astroid = ["astroid (>=2,<5)"] +test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["integration", "unit"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" +optional = false +python-versions = ">3" +groups = ["integration"] +files = [ + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, + {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, +] + +[[package]] +name = "backports-strenum" +version = "1.3.1" +description = "Base class for creating enumerated constants that are also subclasses of str" +optional = false +python-versions = ">=3.8.6,<3.11" +groups = ["integration"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83"}, + {file = "backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414"}, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5"}, + {file = "bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993"}, + {file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"}, + {file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"}, + {file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"}, + {file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"}, + {file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "boto3" +version = "1.42.97" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "boto3-1.42.97-py3-none-any.whl", hash = "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd"}, + {file = "boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0"}, +] + +[package.dependencies] +botocore = ">=1.42.97,<1.43.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.16.0,<0.17.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.42.97" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "botocore-1.42.97-py3-none-any.whl", hash = "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc"}, + {file = "botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.31.2)"] + +[[package]] +name = "certifi" +version = "2026.4.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main", "integration"] +files = [ + {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, + {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main", "integration"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charm-api" +version = "0.1.3" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "charm_api-0.1.3-py3-none-any.whl", hash = "sha256:4dba74e33f3de4608e88db2e08e41b0681221e7d3c2cd6913e0ebeb3dbdf8d92"}, + {file = "charm_api-0.1.3.tar.gz", hash = "sha256:a90a926b89fce834e9fb2184b96e07ec46f4dd56f3cd50e0209c0fd0ff35a29e"}, +] + +[[package]] +name = "charm-json" +version = "0.1.2" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "charm_json-0.1.2-py3-none-any.whl", hash = "sha256:1cad028a92831c61d6dcb99127c5216c333f90ec58d06b9f73fd0fd7355d1b3c"}, + {file = "charm_json-0.1.2.tar.gz", hash = "sha256:f91255858cd6721ec491d09450f37a0cdecf1addbfcfbd41f3e27c58dd95092e"}, +] + +[package.dependencies] +charm-api = ">=0.1.1" + +[[package]] +name = "charm-refresh" +version = "3.1.1.4" +description = "In-place rolling refreshes (upgrades) of stateful charmed applications" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "charm_refresh-3.1.1.4-py3-none-any.whl", hash = "sha256:6ff6e04170c03b3fe3f23bbf32a288a0de3da0d44bf2e8dd850ee02925bf2ec6"}, + {file = "charm_refresh-3.1.1.4.tar.gz", hash = "sha256:d33f66e5b65fd356a30bfbc634710e5538086199da93c30c86246370a66570be"}, +] + +[package.dependencies] +charm-api = ">=0.1.1" +charm-json = ">=0.1.1" +httpx = ">=0.28.1" +lightkube = ">=0.15.4" +ops = ">=2.9.0" +packaging = ">=24.1" +pyyaml = ">=6.0.2" +tomli = ">=2.0.1" + +[[package]] +name = "charm-refresh-build-version" +version = "0.4.1" +description = "Write `charm` version in refresh_versions.toml from git tag" +optional = false +python-versions = ">=3.8" +groups = ["build-refresh-version"] +files = [ + {file = "charm_refresh_build_version-0.4.1-py3-none-any.whl", hash = "sha256:0cd115e6c659d460975afeaf74139dd6feda2845584bce0f49ef0ad00c2173ad"}, + {file = "charm_refresh_build_version-0.4.1.tar.gz", hash = "sha256:b07846a88e9dd03cb09d5ea7c77f556ab06ed67339f66b3bf1a777a9a558ee1d"}, +] + +[package.dependencies] +dunamai = ">=1.23.1" +tomlkit = ">=0.13.2" + +[[package]] +name = "charmlibs-snap" +version = "1.0.1" +description = "The charmlibs.snap package." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "charmlibs_snap-1.0.1-py3-none-any.whl", hash = "sha256:42bae5f55f6dbee014c94f8ab95eef3e249d4903f02412bef05ac5b324aa2783"}, + {file = "charmlibs_snap-1.0.1.tar.gz", hash = "sha256:07a13935909ea4b82c74b8e890b311894bcab2dd1c447246ee392bfd02a74f9c"}, +] + +[package.dependencies] +opentelemetry-api = "*" + +[[package]] +name = "charmlibs-systemd" +version = "1.0.0" +description = "The charmlibs.systemd package." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "charmlibs_systemd-1.0.0-py3-none-any.whl", hash = "sha256:37d4022e28f70f7a2a54fbff7c5694d25dc62dbb8680feffabde8c324a432199"}, + {file = "charmlibs_systemd-1.0.0.tar.gz", hash = "sha256:947e93b076e105509b190020ec16de051e9015c1eb12904192fb39489e0e1caa"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main", "integration"] +files = [ + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, +] + +[[package]] +name = "codespell" +version = "2.4.2" +description = "Fix common misspellings in text files" +optional = false +python-versions = ">=3.9" +groups = ["lint"] +files = [ + {file = "codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886"}, + {file = "codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3"}, +] + +[package.extras] +dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] +hard-encoding-detection = ["chardet"] +toml = ["tomli ; python_version < \"3.11\""] +types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["integration", "unit"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.13.5" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["unit"] +files = [ + {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, + {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, + {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, + {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, + {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, + {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, + {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, + {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, + {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, + {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, + {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, + {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, + {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, + {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, + {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, + {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, + {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, + {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, + {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, + {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, + {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, + {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, + {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "cryptography" +version = "48.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main", "integration"] +files = [ + {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, + {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, + {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, + {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, + {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, + {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, + {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, + {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, + {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, + {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, +] + +[[package]] +name = "dunamai" +version = "1.26.1" +description = "Dynamic version generation" +optional = false +python-versions = ">=3.5" +groups = ["build-refresh-version"] +files = [ + {file = "dunamai-1.26.1-py3-none-any.whl", hash = "sha256:2727d939c5b4257cb01ea404372803b477f5176e5a347c43beaf89cd5072e853"}, + {file = "dunamai-1.26.1.tar.gz", hash = "sha256:3b46007bd65b00b4824ead0a1aee365fd22d0ec2b9c219497d4fd48f52860c8b"}, +] + +[package.dependencies] +packaging = ">=20.9" + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "integration", "unit"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "executing" +version = "2.2.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, + {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] + +[[package]] +name = "google-auth" +version = "2.49.2" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5"}, + {file = "google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409"}, +] + +[package.dependencies] +cryptography = ">=38.0.3" +pyasn1-modules = ">=0.2.1" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] +cryptography = ["cryptography (>=38.0.3)"] +enterprise-cert = ["pyopenssl"] +pyjwt = ["pyjwt (>=2.0)"] +pyopenssl = ["pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0)"] +rsa = ["rsa (>=3.1.4,<5)"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +urllib3 = ["packaging", "urllib3"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "h2" +version = "4.3.0" +description = "Pure-Python HTTP/2 protocol implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, + {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, +] + +[package.dependencies] +hpack = ">=4.1,<5" +hyperframe = ">=6.1,<7" + +[[package]] +name = "hpack" +version = "4.1.0" +description = "Pure-Python HPACK header encoding" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "hvac" +version = "2.4.0" +description = "HashiCorp Vault API client" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["integration"] +files = [ + {file = "hvac-2.4.0-py3-none-any.whl", hash = "sha256:008db5efd8c2f77bd37d2368ea5f713edceae1c65f11fd608393179478649e0f"}, + {file = "hvac-2.4.0.tar.gz", hash = "sha256:e0056ad9064e7923e874e6769015b032580b639e29246f5ab1044f7959c1c7e0"}, +] + +[package.dependencies] +requests = ">=2.27.1,<3.0.0" + +[package.extras] +parser = ["pyhcl (>=0.4.4,<0.5.0)"] + +[[package]] +name = "hyperframe" +version = "6.1.0" +description = "Pure-Python HTTP/2 framing" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, +] + +[[package]] +name = "idna" +version = "3.13" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main", "integration"] +files = [ + {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"}, + {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["integration", "unit"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "invoke" +version = "3.0.3" +description = "Pythonic task execution" +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"}, + {file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"}, +] + +[[package]] +name = "ipdb" +version = "0.13.13" +description = "IPython-enabled pdb" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["integration"] +files = [ + {file = "ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4"}, + {file = "ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726"}, +] + +[package.dependencies] +decorator = {version = "*", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} +tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} + +[[package]] +name = "ipython" +version = "8.39.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +groups = ["integration"] +files = [ + {file = "ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f"}, + {file = "ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt_toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack_data = "*" +traitlets = ">=5.13.0" +typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli ; python_version < \"3.11\"", "typing_extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + +[[package]] +name = "jedi" +version = "0.19.2" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +groups = ["integration"] +files = [ + {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, + {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, +] + +[package.dependencies] +parso = ">=0.8.4,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main", "integration"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jmespath" +version = "1.1.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["unit"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.3.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["unit"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "jubilant" +version = "1.8.0" +description = "Juju CLI wrapper, primarily for charm integration testing" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "jubilant-1.8.0-py3-none-any.whl", hash = "sha256:e0495ee645de5f2df81d044a3b6e2827b5a6277de02c6d30935b9632bd868a98"}, + {file = "jubilant-1.8.0.tar.gz", hash = "sha256:a7cea68299dca94fac3e121a8c8ed92021d20aa2f4461e16822abbcd134d0b8b"}, +] + +[package.dependencies] +PyYAML = "==6.*" + +[[package]] +name = "juju" +version = "3.6.1.3" +description = "Python library for Juju" +optional = false +python-versions = ">=3.8.6" +groups = ["integration"] +files = [ + {file = "juju-3.6.1.3-py3-none-any.whl", hash = "sha256:87469500a0a4e6a3976ddf0595e316379868d5cea96e15af2d2d4b94188f76e5"}, + {file = "juju-3.6.1.3.tar.gz", hash = "sha256:2fcf510fa35b387abb382da3a8b2227f38852ae7e9dc1058afb228588e1aec51"}, +] + +[package.dependencies] +backports-datetime-fromisoformat = ">=2.0.2" +"backports.strenum" = {version = ">=1.3.1", markers = "python_version < \"3.11\""} +hvac = "*" +kubernetes = ">=12.0.1,<31.0.0" +macaroonbakery = ">=1.1,<2.0" +packaging = "*" +paramiko = ">=2.4.0" +pyasn1 = ">=0.4.4" +pyyaml = ">=5.1.2" +toposort = ">=1.5,<2" +typing-extensions = ">=4.5.0" +typing_inspect = ">=0.6.0" +websockets = ">=13.0.1" + +[package.extras] +dev = ["Twine", "freezegun", "pytest", "pytest-asyncio (<=0.25.0)", "typing-inspect"] +docs = ["sphinx (==5.3.0)", "sphinx_rtd_theme", "sphinxcontrib-asyncio"] + +[[package]] +name = "kubernetes" +version = "30.1.0" +description = "Kubernetes python client" +optional = false +python-versions = ">=3.6" +groups = ["integration"] +files = [ + {file = "kubernetes-30.1.0-py2.py3-none-any.whl", hash = "sha256:e212e8b7579031dd2e512168b617373bc1e03888d41ac4e04039240a292d478d"}, + {file = "kubernetes-30.1.0.tar.gz", hash = "sha256:41e4c77af9f28e7a6c314e3bd06a8c6229ddd787cad684e0ab9f69b498e98ebc"}, +] + +[package.dependencies] +certifi = ">=14.5.14" +google-auth = ">=1.0.1" +oauthlib = ">=3.2.2" +python-dateutil = ">=2.5.3" +pyyaml = ">=5.4.1" +requests = "*" +requests-oauthlib = "*" +six = ">=1.9.0" +urllib3 = ">=1.24.2" +websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0" + +[package.extras] +adal = ["adal (>=1.0.2)"] + +[[package]] +name = "landscape-api-py3" +version = "0.9.0" +description = "Client for the Landscape API (Python 3)" +optional = false +python-versions = ">=3.5" +groups = ["integration"] +files = [ + {file = "landscape_api_py3-0.9.0-py2.py3-none-any.whl", hash = "sha256:e55f7a08d3b5aca4ab0c6694ca74c0605c18fb44df8573e7a8df9e01d02d2033"}, + {file = "landscape_api_py3-0.9.0.tar.gz", hash = "sha256:83c3947ecdf4482103e37f58ea99e5e8ff3bb4370f033082bf969deaea38c762"}, +] + +[package.dependencies] +requests = "*" + +[[package]] +name = "lightkube" +version = "0.19.1" +description = "Lightweight kubernetes client library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "lightkube-0.19.1-py3-none-any.whl", hash = "sha256:49fef08a1c7aa42082820111ffd5dbbaf78f54c99385810690fc9d94eef5c80d"}, + {file = "lightkube-0.19.1.tar.gz", hash = "sha256:4c8526068024c194c02fbc0ca6021922feb4b1b9d741d330129f873b27e0fe97"}, +] + +[package.dependencies] +httpx = {version = ">=0.28.1,<1.0.0", extras = ["http2"]} +lightkube-models = ">=1.15.12.0" +pyyaml = "*" + +[package.extras] +jinja-templates = ["jinja2"] + +[[package]] +name = "lightkube-models" +version = "1.35.0.8" +description = "Models and Resources for lightkube module" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "lightkube_models-1.35.0.8-py3-none-any.whl", hash = "sha256:d01fce42f96baf47a77a571bff59d6a513e96ae043fc03cfaaaaf79c609c4441"}, + {file = "lightkube_models-1.35.0.8.tar.gz", hash = "sha256:dbc624596a7d94e6c43c5deda972be964202e0e8f26e2ab8e61d589d710b5e22"}, +] + +[[package]] +name = "macaroonbakery" +version = "1.3.4" +description = "A Python library port for bakery, higher level operation to work with macaroons" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "macaroonbakery-1.3.4-py2.py3-none-any.whl", hash = "sha256:1e952a189f5c1e96ef82b081b2852c770d7daa20987e2088e762dd5689fb253b"}, + {file = "macaroonbakery-1.3.4.tar.gz", hash = "sha256:41ca993a23e4f8ef2fe7723b5cd4a30c759735f1d5021e990770c8a0e0f33970"}, +] + +[package.dependencies] +protobuf = ">=3.20.0" +pymacaroons = ">=0.12.0,<1.0" +PyNaCl = ">=1.1.2,<2.0" +pyRFC3339 = ">=1.0,<2.0" +requests = ">=2.18.1,<3.0" +six = ">=1.11.0,<2.0" + +[[package]] +name = "mailmanclient" +version = "3.3.5" +description = "mailmanclient -- Python bindings for Mailman REST API" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "mailmanclient-3.3.5.tar.gz", hash = "sha256:63581c604ca7eac021489c15aacca06a4958eb76f66574c6fab05eac654dd857"}, +] + +[package.dependencies] +requests = "*" + +[package.extras] +docs = ["pydoctor", "sphinx", "sphinx-issues", "sphinx-rtd-theme"] +lint = ["flake8 (>3.0)", "flake8-bugbear"] +testing = ["falcon (>1.4.1)", "httpx", "mailman (>=3.3.1)", "pytest", "pytest-services"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main", "integration"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76"}, + {file = "matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe"}, +] + +[package.dependencies] +traitlets = "*" + +[package.extras] +test = ["flake8", "nbdime", "nbval", "notebook", "pytest"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, + {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "opentelemetry-api" +version = "1.41.1" +description = "OpenTelemetry Python API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f"}, + {file = "opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621"}, +] + +[package.dependencies] +importlib-metadata = ">=6.0,<8.8.0" +typing-extensions = ">=4.5.0" + +[[package]] +name = "ops" +version = "3.7.0" +description = "The Python library behind great charms" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "ops-3.7.0-py3-none-any.whl", hash = "sha256:7050d5e629ac17de9d443e64f4ad09857e8012c9012c8ba66c9e765899d50bd1"}, + {file = "ops-3.7.0.tar.gz", hash = "sha256:15f04b2fcf1d8bc966cd4405b68a2c71fa24c06f234d5003e89cc4f18ee51a45"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.0,<2.0" +PyYAML = "==6.*" +websocket-client = "==1.*" + +[package.extras] +testing = ["ops-scenario (==8.7.0)"] +tracing = ["ops-tracing (==3.7.0)"] + +[[package]] +name = "packaging" +version = "26.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "build-refresh-version", "integration", "unit"] +files = [ + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, +] + +[[package]] +name = "parameterized" +version = "0.9.0" +description = "Parameterized testing with any Python test framework" +optional = false +python-versions = ">=3.7" +groups = ["unit"] +files = [ + {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, + {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, +] + +[package.extras] +dev = ["jinja2"] + +[[package]] +name = "paramiko" +version = "4.0.0" +description = "SSH2 protocol library" +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9"}, + {file = "paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f"}, +] + +[package.dependencies] +bcrypt = ">=3.2" +cryptography = ">=3.3" +invoke = ">=2.0" +pynacl = ">=1.5" + +[package.extras] +gssapi = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] + +[[package]] +name = "parso" +version = "0.8.6" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +groups = ["integration"] +files = [ + {file = "parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff"}, + {file = "parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "types-setuptools (==67.2.0.1)", "zuban (==0.5.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +groups = ["integration"] +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pgconnstr" +version = "1.0.1" +description = "A tool for parsing and manipulating PostgreSQL libpq style connection strings and URIs" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "pgconnstr-1.0.1-py2.py3-none-any.whl", hash = "sha256:0f65830e7e3b76adf4390a8592ee52343171a17caef7436257e7bc81c44e21a7"}, + {file = "pgconnstr-1.0.1.zip", hash = "sha256:0656129961ae879675d0842f5237db82d31ce59c7b3211b051c33e37a864826e"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["integration", "unit"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "postgresql-charms-single-kernel" +version = "16.1.11" +description = "Shared and reusable code for PostgreSQL-related charms" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "postgresql_charms_single_kernel-16.1.11-py3-none-any.whl", hash = "sha256:a47a97880262fa810d3741110fd7bd7846409865da3631b3d69b0866d004815b"}, + {file = "postgresql_charms_single_kernel-16.1.11.tar.gz", hash = "sha256:2a3de96280ebf0e5e9c22edc3f9f8f49345c274d010f17d5f83e1b1e8a424042"}, +] + +[package.dependencies] +ops = ">=2.0.0" +psycopg2 = ">=2.9.10" +tenacity = ">=9.0.0" + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, + {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "protobuf" +version = "7.34.1" +description = "" +optional = false +python-versions = ">=3.10" +groups = ["integration"] +files = [ + {file = "protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7"}, + {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b"}, + {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a"}, + {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4"}, + {file = "protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a"}, + {file = "protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c"}, + {file = "protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11"}, + {file = "protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280"}, +] + +[[package]] +name = "psycopg2" +version = "2.9.12" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "psycopg2-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:d5fbe092315fb007c03544704e6d1e678a6c0378139d01cea433dc59edf041b4"}, + {file = "psycopg2-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:2532c0cdc6ad18c9c35cd935cc3159712e14f05276a6d29a6435c52d24b840c1"}, + {file = "psycopg2-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:83d48e66e18c301d832e93c984a7bcbc0f4ac3bb79e2137e3bc335978c756dc0"}, + {file = "psycopg2-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:3d23e684927d37b95cee9a943f6927b04ae2fdcd056fd0e2a30929ee89fee5a9"}, + {file = "psycopg2-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:a73d5513bfe929c56555006c7a9cc7ae6e4276aa99dd2b1e2544eb8bb54f8b23"}, + {file = "psycopg2-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:09826a6b89714626a662275d03f21639f1c68d183e2dcc9ba134d463a3da753e"}, + {file = "psycopg2-2.9.12.tar.gz", hash = "sha256:1dedb1c7a1d8552c4a6044c6b1c41a52e6a8e2d144af83eccac758076b1b7c15"}, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10"}, + {file = "psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c"}, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +groups = ["integration"] +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pyasn1" +version = "0.6.3" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, + {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + +[package.dependencies] +pyasn1 = ">=0.6.1,<0.7.0" + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main", "integration"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["charm-libs"] +files = [ + {file = "pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927"}, + {file = "pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.3" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["charm-libs"] +files = [ + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1"}, + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win32.whl", hash = "sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win_amd64.whl", hash = "sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff"}, + {file = "pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["integration", "unit"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymacaroons" +version = "0.13.0" +description = "Macaroon library for Python" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "pymacaroons-0.13.0-py2.py3-none-any.whl", hash = "sha256:3e14dff6a262fdbf1a15e769ce635a8aea72e6f8f91e408f9a97166c53b91907"}, + {file = "pymacaroons-0.13.0.tar.gz", hash = "sha256:1e6bba42a5f66c245adf38a5a4006a99dcc06a0703786ea636098667d42903b8"}, +] + +[package.dependencies] +PyNaCl = ">=1.1.2,<2.0" +six = ">=1.8.0" + +[[package]] +name = "pynacl" +version = "1.6.2" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} + +[package.extras] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + +[[package]] +name = "pyrfc3339" +version = "1.1" +description = "Generate and parse RFC 3339 timestamps" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "pyRFC3339-1.1-py2.py3-none-any.whl", hash = "sha256:67196cb83b470709c580bb4738b83165e67c6cc60e1f2e4f286cfcb402a926f4"}, + {file = "pyRFC3339-1.1.tar.gz", hash = "sha256:81b8cbe1519cdb79bed04910dd6fa4e181faf8c88dff1e1b987b5f7ab23a5b1a"}, +] + +[package.dependencies] +pytz = "*" + +[[package]] +name = "pysyncobj" +version = "0.3.15" +description = "A library for replicating your python class between multiple servers, based on raft protocol" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pysyncobj-0.3.15.tar.gz", hash = "sha256:7b98f523bc7810203763f5a6ef569ff2dd5de889fb7f5f0ccc8c8b6c87df9680"}, +] + +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["integration", "unit"] +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +groups = ["integration", "unit"] +files = [ + {file = "pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b"}, + {file = "pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-operator" +version = "0.43.2" +description = "Fixtures for Charmed Operators" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "pytest_operator-0.43.2-py3-none-any.whl", hash = "sha256:d7d01ffe35d14b75577fd80a07c34f0a9f4835cfc6d373b8e2f995bcb4146bda"}, + {file = "pytest_operator-0.43.2.tar.gz", hash = "sha256:3db34dcd9c114a2e41a9bc61da72daf1264e7644fd5b92e855f250cb337e01c3"}, +] + +[package.dependencies] +ipdb = "*" +jinja2 = "*" +juju = "*" +pytest = "*" +pytest-asyncio = "<0.23" +pyyaml = "*" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["integration"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2026.1.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, + {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "integration"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +groups = ["unit"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + +[[package]] +name = "requests" +version = "2.33.1" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main", "integration"] +files = [ + {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, + {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +groups = ["integration"] +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +groups = ["unit"] +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + +[[package]] +name = "ruff" +version = "0.15.12" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["format"] +files = [ + {file = "ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c"}, + {file = "ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c"}, + {file = "ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e"}, + {file = "ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20"}, + {file = "ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d"}, + {file = "ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f"}, + {file = "ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6"}, +] + +[[package]] +name = "s3transfer" +version = "0.16.1" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["integration"] +files = [ + {file = "s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2"}, + {file = "s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["integration"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["main", "integration"] +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + +[[package]] +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["main", "integration", "unit"] +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] +markers = {unit = "python_full_version <= \"3.11.0a6\""} + +[[package]] +name = "tomli-w" +version = "1.2.0" +description = "A lil' TOML writer" +optional = false +python-versions = ">=3.9" +groups = ["integration", "unit"] +files = [ + {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, + {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.9" +groups = ["build-refresh-version"] +files = [ + {file = "tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680"}, + {file = "tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"}, +] + +[[package]] +name = "toposort" +version = "1.10" +description = "Implements a topological sort algorithm." +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87"}, + {file = "toposort-1.10.tar.gz", hash = "sha256:bfbb479c53d0a696ea7402601f4e693c97b0367837c8898bc6471adfca37a6bd"}, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "ty" +version = "0.0.32" +description = "An extremely fast Python type checker, written in Rust." +optional = false +python-versions = ">=3.8" +groups = ["lint"] +files = [ + {file = "ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83"}, + {file = "ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81"}, + {file = "ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f"}, + {file = "ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57"}, + {file = "ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5"}, + {file = "ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8"}, + {file = "ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6"}, + {file = "ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334"}, + {file = "ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a"}, + {file = "ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb"}, + {file = "ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501"}, + {file = "ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e"}, + {file = "ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175"}, + {file = "ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c"}, + {file = "ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee"}, + {file = "ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658"}, + {file = "ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "charm-libs", "integration", "unit"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {unit = "python_version < \"3.13\""} + +[[package]] +name = "typing-inspect" +version = "0.9.0" +description = "Runtime inspection utilities for typing module." +optional = false +python-versions = "*" +groups = ["integration"] +files = [ + {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, + {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, +] + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["charm-libs"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main", "integration"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "wcwidth" +version = "0.6.0" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.8" +groups = ["integration"] +files = [ + {file = "wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad"}, + {file = "wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159"}, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.9" +groups = ["main", "integration"] +files = [ + {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, + {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["pytest", "websockets"] + +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["integration"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + +[[package]] +name = "zipp" +version = "3.23.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, + {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "956cf31a76d5d3bcbe2b796b7e9c0572b8d12b55412552b754e89295465c5562" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4c9241 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,168 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +[tool.poetry] +package-mode = false +requires-poetry = ">=2.0.0" + +[tool.poetry.dependencies] +python = ">=3.10,<4.0" +ops = "^3.7.0" +cryptography ="^48.0.0" +pgconnstr = "^1.0.1" +requests = "^2.33.1" +tenacity = "^9.1.4" +psycopg2 = "^2.9.12" +jinja2 = "^3.1.6" +pysyncobj = "^0.3.15" +charm-refresh = "^3.1.0.2" +httpx = "^0.28.1" +charmlibs-snap = "^1.0.1" +charmlibs-systemd = "^1.0.0" +postgresql-charms-single-kernel = "16.1.11" + +[tool.poetry.group.charm-libs.dependencies] +# data_platform_libs/v0/data_models.py +pydantic = "*" + +[tool.poetry.group.format] +optional = true + +[tool.poetry.group.format.dependencies] +ruff = "^0.15.12" + +[tool.poetry.group.lint] +optional = true + +[tool.poetry.group.lint.dependencies] +codespell = "^2.4.2" +ty = "^0.0.32" + +[tool.poetry.group.unit] +optional = true + +[tool.poetry.group.unit.dependencies] +coverage = { extras = ["toml"], version = "^7.13.5" } +pytest = "^9.0.3" +pytest-asyncio = "*" +parameterized = "^0.9.0" +jsonschema = "^4.26.0" +tomli-w = "^1.2.0" + +[tool.poetry.group.integration] +optional = true + +[tool.poetry.group.integration.dependencies] +pytest = "^9.0.3" +pytest-operator = "^0.43.2" +# renovate caret doesn't work: https://github.com/renovatebot/renovate/issues/26940 +juju = "<=3.6.1.3" +jubilant = "^1.8.0" +boto3 = "*" +tenacity = "*" +landscape-api-py3 = "^0.9.0" +mailmanclient = "^3.3.5" +psycopg2-binary = "^2.9.12" +allure-pytest = "^2.16.0" +allure-pytest-default-results = "^0.1.4" +tomli = "^2.4.1" +tomli-w = "^1.2.0" + +[tool.poetry.group.build-refresh-version] +optional = true + +[tool.poetry.group.build-refresh-version.dependencies] +charm-refresh-build-version = "^0.4.1" + +# Testing tools configuration +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true +exclude_lines = ["logger\\.debug"] + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" +asyncio_mode = "auto" +markers = ["juju3", "juju_secrets"] +addopts = "--exitfirst" + +# Linting tools configuration +[tool.ruff] +# preview and explicit preview are enabled for CPY001 +preview = true +target-version = "py312" +src = ["src", "."] +line-length = 99 + +[tool.ruff.lint] +explicit-preview-rules = true +select = [ + "A", + "E", + "W", + "F", + "C", + "N", + "D", + "I001", + "B", + "CPY001", + "RUF", + "S", + "SIM", + "UP", + "TC", +] +extend-ignore = [ + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", + "B904", +] +# Ignore E501 because using black creates errors with this +# Ignore D107 Missing docstring in __init__ +ignore = ["E501", "D107"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "D100", + "D101", + "D102", + "D103", + "D104", + # Asserts + "B011", + # Disable security checks for tests + "S", +] + +[tool.ruff.lint.flake8-copyright] +# Check for properly formatted copyright header in each file +author = "Canonical Ltd." +notice-rgx = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+" +min-file-size = 1 + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ty.environment] +python = ".tox/lint/" +extra-paths = ["./lib"] + +[tool.ty.src] +include = ["src", "scripts"] +exclude = ["tests"] diff --git a/refresh_versions.toml b/refresh_versions.toml new file mode 100644 index 0000000..514c882 --- /dev/null +++ b/refresh_versions.toml @@ -0,0 +1,11 @@ +charm_major = 1 +workload = "16.13" + +[snap] +name = "charmed-postgresql" + +[snap.revisions] +# amd64 +x86_64 = "289" +# arm64 +aarch64 = "288" diff --git a/spread.yaml b/spread.yaml new file mode 100644 index 0000000..fa77fac --- /dev/null +++ b/spread.yaml @@ -0,0 +1,133 @@ +project: postgresql-watcher-operator + +backends: + # Derived from https://github.com/jnsgruk/zinc-k8s-operator/blob/a21eae8399eb3b9df4ddb934b837af25ef831976/spread.yaml#L11 + lxd-vm: + # TODO: remove after https://github.com/canonical/spread/pull/185 merged & in charmcraft + type: adhoc + allocate: | + hash=$(python3 -c "import hashlib; print(hashlib.sha256('$SPREAD_PASSWORD'.encode()).hexdigest()[:6])") + VM_NAME="${VM_NAME:-${SPREAD_SYSTEM//./-}-${hash}}" + DISK="${DISK:-20}" + CPU="${CPU:-4}" + MEM="${MEM:-8}" + + cloud_config="#cloud-config + ssh_pwauth: true + users: + - default + - name: runner + plain_text_passwd: $SPREAD_PASSWORD + lock_passwd: false + sudo: ALL=(ALL) NOPASSWD:ALL + " + + lxc launch --vm \ + "${SPREAD_SYSTEM//-/:}" \ + "${VM_NAME}" \ + -c user.user-data="${cloud_config}" \ + -c limits.cpu="${CPU}" \ + -c limits.memory="${MEM}GiB" \ + -d root,size="${DISK}GiB" + + # Wait for the runner user + while ! lxc exec "${VM_NAME}" -- id -u runner &>/dev/null; do sleep 0.5; done + + # Set the instance address for spread + ADDRESS "$(lxc ls -f csv | grep "${VM_NAME}" | cut -d"," -f3 | cut -d" " -f1)" + discard: | + hash=$(python3 -c "import hashlib; print(hashlib.sha256('$SPREAD_PASSWORD'.encode()).hexdigest()[:6])") + VM_NAME="${VM_NAME:-${SPREAD_SYSTEM//./-}-${hash}}" + lxc delete --force "${VM_NAME}" + environment: + CONCIERGE_EXTRA_SNAPS: charmcraft + CONCIERGE_EXTRA_DEBS: pipx + systems: + - ubuntu-22.04: + username: runner + prepare: | + systemctl disable --now unattended-upgrades.service + systemctl mask unattended-upgrades.service + pipx install charmcraftcache + cd "$SPREAD_PATH" + charmcraftcache pack -v + restore-each: | + cd "$SPREAD_PATH" + # Revert python-libjuju version override + git restore pyproject.toml poetry.lock + + # Use instead of `concierge restore` to save time between tests + # For example, with microk8s, using `concierge restore` takes twice as long as this (e.g. 6 + # min instead of 3 min between every spread job) + juju destroy-model --force --no-wait --destroy-storage --no-prompt testing + juju kill-controller --no-prompt concierge-lxd + restore: | + rm -rf "$SPREAD_PATH" + + github-ci: + type: adhoc + # Only run on CI + manual: true + # HACK: spread requires runners to be accessible via SSH + # Configure local sshd & instruct spread to connect to the same machine spread is running on + # (spread cannot provision GitHub Actions runners, so we provision a GitHub Actions runner for + # each spread job & select a single job when running spread) + # Derived from https://github.com/jnsgruk/zinc-k8s-operator/blob/a21eae8399eb3b9df4ddb934b837af25ef831976/spread.yaml#L47 + allocate: | + sudo tee /etc/ssh/sshd_config.d/10-spread-github-ci.conf << 'EOF' + PasswordAuthentication yes + EOF + + echo "runner:$SPREAD_PASSWORD" | sudo chpasswd + + # Remove the preinstalled lxd + sudo snap remove --purge --terminate lxd + + ADDRESS localhost + + # HACK: spread does not pass environment variables set on runner + # Manually pass specific environment variables + environment: + CI: '$(HOST: echo $CI)' + AWS_ACCESS_KEY: '$(HOST: echo $AWS_ACCESS_KEY)' + AWS_SECRET_KEY: '$(HOST: echo $AWS_SECRET_KEY)' + GCP_ACCESS_KEY: '$(HOST: echo $GCP_ACCESS_KEY)' + GCP_SECRET_KEY: '$(HOST: echo $GCP_SECRET_KEY)' + UBUNTU_PRO_TOKEN: '$(HOST: echo $UBUNTU_PRO_TOKEN)' + LANDSCAPE_ACCOUNT_NAME: '$(HOST: echo $LANDSCAPE_ACCOUNT_NAME)' + LANDSCAPE_REGISTRATION_KEY: '$(HOST: echo $LANDSCAPE_REGISTRATION_KEY)' + systems: + - ubuntu-22.04: + username: runner + - ubuntu-24.04-arm: + username: runner + +suites: + tests/spread/: + summary: Spread tests + +path: /root/spread_project + +kill-timeout: 3h +environment: + PATH: $PATH:$(pipx environment --value PIPX_BIN_DIR) + CONCIERGE_JUJU_CHANNEL/juju36: 3.6/stable +prepare: | + snap refresh --hold + chown -R root:root "$SPREAD_PATH" + cd "$SPREAD_PATH" + snap install --classic concierge + + # Install charmcraft & pipx (on lxd-vm backend) + concierge prepare --trace + + pipx install tox poetry +prepare-each: | + cd "$SPREAD_PATH" + # `concierge prepare` needs to be run for each spread job in case Juju version changed + concierge prepare --trace + + # Unable to set constraint on all models because of Juju bug: + # https://bugs.launchpad.net/juju/+bug/2065050 + juju set-model-constraints arch="$(dpkg --print-architecture)" +# Only restore on lxd backend—no need to restore on CI diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..0f7c7a5 --- /dev/null +++ b/src/charm.py @@ -0,0 +1,273 @@ +#!/usr/bin/env -S LD_LIBRARY_PATH=lib python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charmed Machine Operator for the PostgreSQL database.""" + +import dataclasses +import json +import logging +import pathlib +import platform +from typing import Literal + +import charm_refresh +import ops.log +import tomli +from charmlibs import snap +from charms.data_platform_libs.v1.data_models import TypedCharmBase +from ops import ( + BlockedStatus, + JujuVersion, + Relation, + SecretRemoveEvent, + main, +) +from single_kernel_postgresql.config.literals import PEER + +from config import CharmConfig +from raft_controller import install_service +from relations.watcher_requirer import WatcherRequirerHandler + +logger = logging.getLogger(__name__) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) +logging.getLogger("asyncio").setLevel(logging.WARNING) +logging.getLogger("boto3").setLevel(logging.WARNING) +logging.getLogger("botocore").setLevel(logging.WARNING) + +SCOPES = Literal["app", "unit"] + + +@dataclasses.dataclass(eq=False) +class _PostgreSQLRefresh(charm_refresh.CharmSpecificMachines): + _charm: "PostgresqlWatcherCharm" + + @staticmethod + def run_pre_refresh_checks_after_1_unit_refreshed() -> None: + pass + + def run_pre_refresh_checks_before_any_units_refreshed(self) -> None: + if self._charm._peers is None: + # This should not happen since `charm_refresh.PeerRelationNotReady` should've been + # raised, so this code would not run + raise ValueError + + @classmethod + def is_compatible( + cls, + *, + old_charm_version: charm_refresh.CharmVersion, + new_charm_version: charm_refresh.CharmVersion, + old_workload_version: str, + new_workload_version: str, + ) -> bool: + # Check charm version compatibility + if not super().is_compatible( + old_charm_version=old_charm_version, + new_charm_version=new_charm_version, + old_workload_version=old_workload_version, + new_workload_version=new_workload_version, + ): + return False + + # Check workload version compatibility + old_major, old_minor = (int(component) for component in old_workload_version.split(".")) + new_major, new_minor = (int(component) for component in new_workload_version.split(".")) + if old_major != new_major: + return False + return new_minor >= old_minor + + def refresh_snap( + self, *, snap_name: str, snap_revision: str, refresh: charm_refresh.Machines + ) -> None: + pass + + +class PostgresqlWatcherCharm(TypedCharmBase[CharmConfig]): + """Charmed Operator for the PostgreSQL database.""" + + config_type = CharmConfig + + def __init__(self, *args): + super().__init__(*args) + # Show logger name (module name) in logs + root_logger = logging.getLogger() + for handler in root_logger.handlers: + if isinstance(handler, ops.log.JujuLogHandler): + handler.setFormatter(logging.Formatter("{name}:{message}", style="{")) + + # Watcher mode: lightweight Raft witness, no PostgreSQL + self._init_watcher_mode() + # Set tracing_endpoint for @trace_charm decorator compatibility + self.tracing_endpoint = None + + self.refresh: charm_refresh.Machines | None + try: + self.refresh = charm_refresh.Machines( + _PostgreSQLRefresh( + workload_name="PostgreSQL", charm_name="postgresql", _charm=self + ) + ) + except (charm_refresh.UnitTearingDown, charm_refresh.PeerRelationNotReady): + self.refresh = None + self._reconcile_refresh_status() + + if self.refresh is not None and not self.refresh.next_unit_allowed_to_refresh: + if self.refresh.in_progress: + self._post_snap_refresh(self.refresh) + else: + self.refresh.next_unit_allowed_to_refresh = True + + def _init_watcher_mode(self): + """Initialize the charm in watcher mode (lightweight Raft witness).""" + self.watcher_requirer = WatcherRequirerHandler(self) + # Watcher mode delegates all event handling to WatcherRequirerHandler. + # We still observe leader_elected to persist the role in peer data. + + def _post_snap_refresh(self, refresh: charm_refresh.Machines): + """Start PostgreSQL, check if this app and unit are healthy, and allow next unit to refresh. + + Called after snap refresh + """ + install_service() + refresh.next_unit_allowed_to_refresh = True + + def set_unit_status( + self, status: ops.StatusBase, /, *, refresh: charm_refresh.Machines | None = None + ): + """Set unit status without overriding higher priority refresh status.""" + if refresh is None: + refresh = self.refresh + if refresh is not None and refresh.unit_status_higher_priority: + return + if ( + isinstance(status, ops.ActiveStatus) + and refresh is not None + and (refresh_status := refresh.unit_status_lower_priority()) + ): + self.unit.status = refresh_status + pathlib.Path(".last_refresh_unit_status.json").write_text( + json.dumps(refresh_status.message) + ) + return + self.unit.status = status + + def _reconcile_refresh_status(self, _=None): + # Workaround for other unit statuses being set in a stateful way (i.e. unable to recompute + # status on every event) + path = pathlib.Path(".last_refresh_unit_status.json") + try: + last_refresh_unit_status = json.loads(path.read_text()) + except FileNotFoundError: + last_refresh_unit_status = None + new_refresh_unit_status = None + if self.refresh is not None and self.refresh.unit_status_higher_priority: + self.unit.status = self.refresh.unit_status_higher_priority + new_refresh_unit_status = self.refresh.unit_status_higher_priority.message + elif self.unit.status.message == last_refresh_unit_status: + if self.refresh is not None and ( + refresh_status := self.refresh.unit_status_lower_priority() + ): + self.unit.status = refresh_status + new_refresh_unit_status = refresh_status.message + elif ( + isinstance(self.unit.status, ops.ActiveStatus) + and self.refresh is not None + and (refresh_status := self.refresh.unit_status_lower_priority()) + ): + self.unit.status = refresh_status + new_refresh_unit_status = refresh_status.message + path.write_text(json.dumps(new_refresh_unit_status)) + + @property + def app_peer_data(self) -> dict: + """Application peer relation data object.""" + return self.all_peer_data.get(self.app, {}) + + @property + def unit_peer_data(self) -> dict: + """Unit peer relation data object.""" + return self.all_peer_data.get(self.unit, {}) + + @property + def all_peer_data(self) -> dict: + """Return all peer data if available.""" + if self._peers is None: + return {} + + # RelationData has dict like API + return self._peers.data # type: ignore + + def _on_secret_remove(self, event: SecretRemoveEvent) -> None: + if self.model.juju_version < JujuVersion("3.6.11"): + logger.warning( + "Skipping secret revision removal due to https://github.com/juju/juju/issues/20782" + ) + return + + # A secret removal (entire removal, not just a revision removal) causes + # https://github.com/juju/juju/issues/20794. This check is to avoid the + # errors that would happen if we tried to remove the revision in that case + # (in the revision removal, the label is present). + if event.secret.label is None: + logger.debug("Secret with no label cannot be removed") + return + logger.debug(f"Removing secret with label {event.secret.label} revision {event.revision}") + event.remove_revision() + + @property + def is_blocked(self) -> bool: + """Returns whether the unit is in a blocked state.""" + return isinstance(self.unit.status, BlockedStatus) + + def _install_snap_package( + self, *, revision: str | None, refresh: charm_refresh.Machines | None = None + ) -> None: + """Installs PostgreSQL snap. + + Args: + revision: snap revision to install. + refresh: refresh class; will refresh installed snap if not `None` + """ + if revision is None: + if refresh is not None: + raise ValueError + # TODO: consider using `self.refresh.pinned_snap_revision` instead (requires waiting + # for refresh peer relation to be ready before installing snap) + with pathlib.Path("refresh_versions.toml").open("rb") as file: + revisions = tomli.load(file)["snap"]["revisions"] + try: + revision = revisions[platform.machine()] + except KeyError: + logger.error("Unavailable snap architecture %s", platform.machine()) + raise + try: + snap_cache = snap.SnapCache() + snap_package = snap_cache[charm_refresh.snap_name()] + if not snap_package.present or refresh is not None: + snap_package.ensure(snap.SnapState.Present, revision=revision) + if refresh is not None: + refresh.update_snap_revision() + snap_package.hold() + except (snap.SnapError, snap.SnapNotFoundError) as e: + logger.error( + "An exception occurred when installing %s. Reason: %s", + charm_refresh.snap_name(), + str(e), + ) + raise + + @property + def _peers(self) -> Relation | None: + """Fetch the peer relation. + + Returns: + A:class:`ops.model.Relation` object representing + the peer relation. + """ + return self.model.get_relation(PEER) + + +if __name__ == "__main__": + main(PostgresqlWatcherCharm) diff --git a/src/cluster.py b/src/cluster.py new file mode 100644 index 0000000..239b73e --- /dev/null +++ b/src/cluster.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Helper class used to manage cluster lifecycle.""" + +import logging +from typing import TypedDict + +logger = logging.getLogger(__name__) + + +class ClusterMember(TypedDict): + """Type for cluster member.""" + + name: str + role: str + state: str + api_url: str + host: str + port: int + timeline: int + lag: int diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..854e23b --- /dev/null +++ b/src/config.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Structured configuration for the PostgreSQL charm.""" + +from typing import Literal + +from charms.data_platform_libs.v1.data_models import BaseConfigModel + + +class CharmConfig(BaseConfigModel): + """Manager for the structured configuration.""" + + profile: Literal["testing", "production"] + + @classmethod + def keys(cls) -> list[str]: + """Return config as list items.""" + return list(cls.model_fields.keys()) + + @classmethod + def plugin_keys(cls) -> filter: + """Return plugin config names in a iterable.""" + return filter(lambda x: x.startswith("plugin_"), cls.keys()) diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..ec8b813 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,14 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""File containing constants to be used in the charm.""" + +API_REQUEST_TIMEOUT = 5 +PATRONI_CLUSTER_STATUS_ENDPOINT = "cluster" +PEER = "database-peers" + +# # Watcher constants +WATCHER_RELATION = "watcher" + +RAFT_PORT = 2222 +RAFT_PARTNER_PREFIX = "partner_node_status_server_" diff --git a/src/raft_controller.py b/src/raft_controller.py new file mode 100644 index 0000000..6cd939f --- /dev/null +++ b/src/raft_controller.py @@ -0,0 +1,493 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Raft controller management for PostgreSQL watcher. + +This module manages a Patroni raft_controller node that participates in +consensus without running PostgreSQL, providing the necessary third vote +for quorum in 2-node PostgreSQL clusters. + +Uses Patroni's own ``patroni_raft_controller`` from the charmed-postgresql +snap, which is the same battle-tested Raft implementation used by the +PostgreSQL nodes. This guarantees wire compatibility with Patroni's +KVStoreTTL class. + +The Raft service runs as a systemd service to ensure it persists between +charm hook invocations. +""" + +import logging +from contextlib import suppress +from ipaddress import ip_address +from shutil import rmtree +from typing import TYPE_CHECKING, TypedDict + +import psycopg2 +from charmlibs.systemd import ( + SystemdError, + daemon_reload, + service_disable, + service_enable, + service_restart, + service_running, + service_start, + service_stop, +) +from jinja2 import Template +from pysyncobj.utility import TcpUtility +from tenacity import RetryError, Retrying, stop_after_attempt, wait_fixed + +from cluster import ClusterMember +from constants import PATRONI_CLUSTER_STATUS_ENDPOINT, RAFT_PARTNER_PREFIX, RAFT_PORT +from utils import create_directory, parallel_patroni_get_request, render_file + +if TYPE_CHECKING: + from charm import PostgresqlWatcherCharm + +logger = logging.getLogger(__name__) + +# Base directory for all Raft instances. +# Must be under the snap's common path so that +# charmed-postgresql.patroni-raft-controller can access it. +RAFT_BASE_DIR = "/var/snap/charmed-postgresql/common/watcher-raft" +SERVICE_FILE = "/etc/systemd/system/watcher-raft@.service" + +# Default health check configuration +DEFAULT_RETRY_COUNT = 3 +DEFAULT_RETRY_INTERVAL_SECONDS = 7 +DEFAULT_QUERY_TIMEOUT_SECONDS = 5 +DEFAULT_CHECK_INTERVAL_SECONDS = 10 + +# TCP keepalive settings to detect dead connections quickly +TCP_KEEPALIVE_IDLE = 1 # Start keepalive probes after 1 second of idle +TCP_KEEPALIVE_INTERVAL = 1 # Send keepalive probes every 1 second +TCP_KEEPALIVE_COUNT = 3 # Consider connection dead after 3 failed probes + + +class ClusterStatus(TypedDict): + """Type definition for the cluster status mapping.""" + + running: bool + connected: bool + has_quorum: bool + leader: str | None + members: list[str] + + +def install_service() -> None: + """Install the systemd template service for the Raft controller. + + Returns: + True if the service file was updated, False if unchanged. + """ + with open("templates/watcher.service.j2") as file: + template = Template(file.read()) + + rendered = template.render(config_file=RAFT_BASE_DIR) + render_file(SERVICE_FILE, rendered, 0o644, change_owner=False) + + # Reload systemd to pick up the new service + daemon_reload() + logger.info(f"Installed systemd service {SERVICE_FILE}") + + +class RaftController: + """Manages the Raft service for consensus participation. + + The Raft service runs as a systemd service to ensure it persists + between charm hook invocations. This is necessary because: + 1. Each hook invocation creates a new Python process + 2. pysyncobj requires a persistent process for Raft consensus + 3. The systemd service ensures the Raft node stays running + """ + + def __init__(self, charm: "PostgresqlWatcherCharm", instance_id: str = "default"): + """Initialize the Raft controller. + + Args: + charm: The PostgreSQL watcher charm instance. + instance_id: Unique identifier for this Raft instance. Used to + derive data directories, config files, and service names. + Defaults to "default" for backward compatibility. + + """ + self.charm = charm + self.instance_id = instance_id + + # Derive all paths from instance_id + self.data_dir = f"{RAFT_BASE_DIR}/{instance_id}" + self.config_file = f"{RAFT_BASE_DIR}/{instance_id}/patroni-raft.yaml" + self.ca_file = f"{RAFT_BASE_DIR}/{instance_id}/patroni-ca.pem" + self.service_name = f"watcher-raft@{instance_id}" + + def configure( + self, + self_port: int, + self_addr: str | None = None, + partner_addrs: list[str] | None = None, + password: str | None = None, + cas: str | None = None, + ) -> bool: + """Configure the Raft controller. + + Args: + self_port: This node's Raft port. + self_addr: This node's Raft address. + partner_addrs: List of partner Raft addresses. + password: Raft cluster password. + cas: Patroni CA bundle. + + Returns: + True if configuration changed, False if unchanged. + """ + if not partner_addrs: + partner_addrs = [] + + # Ensure data directory exists + create_directory(self.data_dir, 0o700) + create_directory(f"{self.data_dir}/raft", 0o700) + + if not self_addr or not password: + logger.warning("Cannot install service: not configured") + return False + + # Validate addresses to prevent injection into the systemd unit file + try: + ip_address(self_addr) + except Exception: + logger.error(f"Invalid self_addr format: {self_addr}") + return False + try: + for addr in partner_addrs: + ip_address(addr) + except Exception: + logger.error(f"Invalid partner address format: {addr}") + return False + + with open("templates/watcher.yml.j2") as file: + template = Template(file.read()) + + # Write Patroni-compatible YAML config (includes password) + rendered = template.render( + self_addr=self_addr, + self_port=self_port, + partner_addrs=partner_addrs, + password=password, + data_dir=self.data_dir, + ) + render_file(self.config_file, rendered, 0o600) + if cas: + render_file(self.ca_file, cas, 0o600) + + logger.info(f"Raft controller configured: self={self_addr}, partners={partner_addrs}") + return True + + def start(self) -> bool: + """Start the Raft controller service. + + Returns: + True if started successfully, False otherwise. + """ + if service_running(self.service_name): + logger.debug("Raft controller already running") + return True + + try: + # Enable and start the service + service_enable(self.service_name) + service_start(self.service_name) + logger.info(f"Started Raft controller service {self.service_name}") + return True + except SystemdError as e: + logger.error(f"Failed to start Raft controller: {e}") + return False + + def stop(self) -> bool: + """Stop the Raft controller service. + + Returns: + True if stopped successfully, False otherwise. + """ + if not service_running(self.service_name): + logger.debug("Raft controller not running") + return True + + try: + service_stop(self.service_name) + logger.info(f"Stopped Raft controller service {self.service_name}") + return True + except SystemdError as e: + logger.error(f"Failed to stop Raft controller: {e}") + return False + + def remove_service(self) -> bool: + """Disable and remove the Raft systemd service unit file.""" + if not self.stop(): + return False + + try: + service_disable(self.service_name) + except SystemdError as e: + logger.error(f"Failed to disable Raft controller service: {e}") + return False + + try: + with suppress(FileNotFoundError): + rmtree(self.data_dir) + except Exception as e: + logger.error(f"Failed to remove Raft controller directory: {e}") + return False + + return True + + def restart(self) -> bool: + """Restart the Raft controller service. + + Returns: + True if restarted successfully, False otherwise. + """ + try: + service_enable(self.service_name) + service_restart(self.service_name) + logger.info(f"Restarted Raft controller service {self.service_name}") + return True + except SystemdError as e: + logger.error(f"Failed to restart Raft controller: {e}") + return False + + def get_stale_watchers( + self, member_address: str, raft_password: str, partner_addrs: list[str], port: int + ) -> list[str]: + """Collect stale watcher raft members.""" + port_postfix = str(port) + watcher_addr = f"{member_address}:{port}" + watcher_key = f"{RAFT_PARTNER_PREFIX}{watcher_addr}" + + # Get the status of the raft cluster. + syncobj_util = TcpUtility(password=raft_password, timeout=3) + + stale_addrs = [] + addrs = [watcher_addr, *[f"{addr}:{RAFT_PORT}" for addr in partner_addrs]] + for raft_host in addrs: + try: + raft_status = syncobj_util.executeCommand(raft_host, ["status"]) + except Exception as e: + logger.warning(f"Collect stale addrs: Cannot connect to raft cluster: {e}") + continue + if not raft_status: + logger.warning("Collect stale addrs: No raft status") + continue + for key in raft_status: + if ( + key.startswith(RAFT_PARTNER_PREFIX) + and key.endswith(port_postfix) + and key != watcher_key + ): + stale_addrs.append(key.split(RAFT_PARTNER_PREFIX)[-1]) + return stale_addrs + logger.warning("Collect stale addrs: No member available") + return stale_addrs + + def remove_raft_member( + self, member_address: str, raft_password: str, partner_addrs: list[str] + ) -> None: + """Remove a member from the raft cluster. + + The raft cluster is a different cluster from the Patroni cluster. + It is responsible for defining which Patroni member can update + the primary member in the DCS. + + Raises: + RaftMemberNotFoundError: if the member to be removed + is not part of the raft cluster. + """ + if not member_address: + logger.debug("Remove raft member: No address provided") + return + + # Get the status of the raft cluster. + syncobj_util = TcpUtility(password=raft_password, timeout=3) + + for raft_host in [f"{addr}:{RAFT_PORT}" for addr in partner_addrs]: + try: + raft_status = syncobj_util.executeCommand(raft_host, ["status"]) + except Exception as e: + logger.warning(f"Remove raft watcher: Cannot connect to raft cluster: {e}") + continue + if not raft_status: + logger.warning("Remove raft watcher: No raft status") + continue + + # Check whether the member is still part of the raft cluster. + if f"{RAFT_PARTNER_PREFIX}{member_address}" not in raft_status: + return + + # If there's no quorum and the leader left raft cluster is stuck + if not raft_status["has_quorum"] or not raft_status["leader"]: + logger.warning("Remove raft watcher: No quorum or leader") + continue + + # Remove the member from the raft cluster. + try: + result = syncobj_util.executeCommand(raft_host, ["remove", member_address]) + except Exception as e: + logger.debug(f"Remove raft watcher: Remove call failed {e}") + continue + + if not result or not result.startswith("SUCCESS"): + logger.debug(f"Remove raft watcher: Remove call not successful with {result}") + continue + return + + def get_status(self, self_port: int, password: str | None) -> ClusterStatus: + """Get the Raft controller status. + + Returns: + Dictionary with status information. + """ + is_running = service_running(self.service_name) + status: ClusterStatus = { + "running": is_running, + "connected": False, + "has_quorum": False, + "leader": None, + "members": [], + } + + if not password or not is_running: + return status + + # Query Raft status using pysyncobj TcpUtility + try: + utility = TcpUtility(password=password, timeout=3) + raft_status = utility.executeCommand(f"localhost:{self_port}", ["status"]) + status["connected"] = True + status["has_quorum"] = raft_status.get("has_quorum", False) + status["leader"] = ( + str(raft_status.get("leader")) if raft_status.get("leader") else None + ) + + # Extract member addresses from partner_node_status_server_* keys + members: list[str] = [str(raft_status["self"])] + for key in raft_status: + if key.startswith(RAFT_PARTNER_PREFIX): + members.append(key[len(RAFT_PARTNER_PREFIX) :]) + status["members"] = sorted(members) + return status + except Exception as e: + logger.debug(f"Error querying Raft status via TcpUtility: {e}") + + return status + + def check_all_endpoints(self, endpoints: list[str], password: str) -> dict[str, bool]: + """Test connectivity to all PostgreSQL endpoints. + + WARNING: This method uses blocking time.sleep() for retry intervals + (up to ~38s worst case with 2 endpoints). Only call from Juju actions, + never from hook handlers. + + Args: + endpoints: List of PostgreSQL unit IP addresses. + password: Password for the watcher user. + + Returns: + Dictionary mapping endpoint IP to health status data. + """ + results: dict[str, bool] = {} + for endpoint in endpoints: + results[endpoint] = self._check_endpoint_with_retries(endpoint, password) + + self._last_health_results = results + return results + + def _check_endpoint_with_retries(self, endpoint: str, password: str) -> bool: + """Check a single endpoint with retry logic. + + Per acceptance criteria: Repeat tests at least 3 times before + deciding that an instance is no longer reachable, waiting 7 seconds + between every try. + + Args: + endpoint: PostgreSQL endpoint IP address. + password: Password for the watcher user. + + Returns: + Dictionary with health status data. + """ + with suppress(RetryError): + for attempt in Retrying( + stop=stop_after_attempt(DEFAULT_RETRY_COUNT), + wait=wait_fixed(DEFAULT_RETRY_INTERVAL_SECONDS), + ): + with attempt: + if result := self._execute_health_query(endpoint, password): + logger.debug(f"Health check passed for {endpoint}") + return result + raise Exception(f"Cannot reach {endpoint}") + + logger.error(f"Endpoint {endpoint} unhealthy after {DEFAULT_RETRY_COUNT} attempts") + return False + + def _execute_health_query(self, endpoint: str, password: str) -> bool: + """Execute health check queries with TCP keepalive and timeout. + + Per acceptance criteria: + - Testing actual queries (SELECT 1) + - Using direct and reserved connections (no pgbouncer) + - Setting TCP keepalive to avoid hanging on dead connections + - Setting query timeout + + Args: + endpoint: PostgreSQL endpoint IP address. + password: Password for the watcher user. + + Returns: + Dictionary with health info (is_in_recovery, etc.) or None if failed. + """ + connection = None + result = False + try: + # Connect directly to PostgreSQL port 5432 (not pgbouncer 6432) + # Using the 'postgres' database which always exists + with ( + psycopg2.connect( + host=endpoint, + port=5432, + dbname="postgres", + user="watcher", + password=password, + connect_timeout=DEFAULT_QUERY_TIMEOUT_SECONDS, + # TCP keepalive settings per acceptance criteria + keepalives=1, + keepalives_idle=TCP_KEEPALIVE_IDLE, + keepalives_interval=TCP_KEEPALIVE_INTERVAL, + keepalives_count=TCP_KEEPALIVE_COUNT, + # Set options for query timeout + options=f"-c statement_timeout={DEFAULT_QUERY_TIMEOUT_SECONDS * 1000}", + ) as connection, + connection.cursor() as cursor, + ): + # Query recovery status to determine primary vs replica + cursor.execute("SELECT 1") + result = True + + except psycopg2.Error as e: + # Other database errors + logger.debug(f"Database error on {endpoint}: {e}") + finally: + if connection is not None: + try: + connection.close() + except psycopg2.Error as e: + logger.debug(f"Failed to close connection to {endpoint}: {e}") + return result + + def cluster_status(self, endpoints: list[str]) -> list[ClusterMember]: + """Query the cluster status.""" + # Request info from cluster endpoint (which returns all members of the cluster). + if response := parallel_patroni_get_request( + f"/{PATRONI_CLUSTER_STATUS_ENDPOINT}", endpoints, self.ca_file, None + ): + logger.debug("API cluster_status: %s", response["members"]) + return response["members"] + return [] diff --git a/src/relations/watcher_requirer.py b/src/relations/watcher_requirer.py new file mode 100644 index 0000000..9357315 --- /dev/null +++ b/src/relations/watcher_requirer.py @@ -0,0 +1,524 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""PostgreSQL Watcher Requirer Relation implementation. + +This module handles the watcher (requirer) side of the relation, used when the +charm is deployed with role=watcher. It connects to one or more PostgreSQL +applications (which provide the watcher-offer relation) and participates in +Raft consensus as a lightweight witness for stereo mode (2-node clusters). + +Multi-cluster support: +- Each watcher relation gets its own RaftController instance +- Ports are assigned dynamically starting from RAFT_PORT (2223) and persisted + in a port allocation file at /var/snap/charmed-postgresql/common/watcher-raft/ports.json +- Each RaftController uses instance-specific data directories and systemd services +""" + +import json +import logging +import os +import typing +from datetime import datetime + +from charmlibs.systemd import service_running +from ops import ( + ActiveStatus, + BlockedStatus, + InstallEvent, + MaintenanceStatus, + Object, + Relation, + RelationBrokenEvent, + RelationChangedEvent, + RelationJoinedEvent, + SecretChangedEvent, + SecretNotFoundError, + StartEvent, + UpdateStatusEvent, + WaitingStatus, +) + +from constants import RAFT_PORT, WATCHER_RELATION +from raft_controller import RaftController, install_service + +if typing.TYPE_CHECKING: + from charm import PostgresqlWatcherCharm + +logger = logging.getLogger(__name__) + +SNAP_NAME = "charmed-postgresql" +SNAP_CHANNEL = "16/edge" + + +class WatcherRequirerHandler(Object): + """Handles the watcher requirer relation and watcher-mode lifecycle.""" + + def __init__(self, charm: "PostgresqlWatcherCharm"): + super().__init__(charm, WATCHER_RELATION) + self.charm = charm + + # Lifecycle events + self.framework.observe(self.charm.on.install, self._on_install) + self.framework.observe(self.charm.on.leader_elected, self._on_leader_elected) + self.framework.observe(self.charm.on.start, self._on_start) + self.framework.observe(self.charm.on.update_status, self._on_update_status) + + # Relation events + self.framework.observe( + self.charm.on[WATCHER_RELATION].relation_joined, + self._on_watcher_relation_joined, + ) + self.framework.observe( + self.charm.on[WATCHER_RELATION].relation_changed, + self._on_watcher_relation_changed, + ) + self.framework.observe( + self.charm.on.secret_changed, + self._on_watcher_relation_changed, + ) + self.framework.observe( + self.charm.on[WATCHER_RELATION].relation_broken, + self._on_watcher_relation_broken, + ) + + @property + def unit_ip(self) -> str | None: + """Return this unit's IP address.""" + if binding := self.model.get_binding(WATCHER_RELATION): + return str(binding.network.bind_address) + return None + + @property + def is_related(self) -> bool: + """Check if the watcher is related to any PostgreSQL cluster.""" + relations = self.model.relations.get(WATCHER_RELATION, []) + return len(relations) > 0 + + # -- Port allocation -- + + def _load_port_allocations(self) -> dict[str, int]: + """Load port allocations from persistent file. + + Returns: + Dictionary mapping relation_id (as string) to port number. + """ + if "port-allocations" in self.charm.app_peer_data: + return json.loads(self.charm.app_peer_data["port-allocations"]) + return {} + + def _save_port_allocations(self, allocations: dict[str, int]) -> None: + """Save port allocations to persistent file.""" + self.charm.app_peer_data["port-allocations"] = json.dumps(allocations) + + def _is_disabled(self, relation: Relation) -> bool: + """Is disabled flag set.""" + if not relation: + return False + return "disable-watcher" in relation.data[relation.app] + + def _get_port_for_relation(self, relation_id: int) -> int: + """Get or assign a port for a given relation ID. + + Args: + relation_id: The Juju relation ID. + + Returns: + The assigned port number. + """ + allocations = self._load_port_allocations() + key = str(relation_id) + + if key in allocations: + return allocations[key] + + # Assign next available port starting from RAFT_PORT + used_ports = set(allocations.values()) + port = RAFT_PORT + 1 + while port in used_ports: + port += 1 + + allocations[key] = port + self._save_port_allocations(allocations) + logger.info(f"Assigned port {port} to relation {relation_id}") + return port + + def _release_port_for_relation(self, relation_id: int) -> None: + """Release the port allocated for a relation. + + Args: + relation_id: The Juju relation ID. + """ + allocations = self._load_port_allocations() + key = str(relation_id) + if key in allocations: + port = allocations.pop(key) + self._save_port_allocations(allocations) + logger.info(f"Released port {port} from relation {relation_id}") + + # -- Per-relation helpers -- + + def _get_raft_password(self, relation: Relation) -> str | None: + """Get the Raft password from the relation secret. + + Args: + relation: The specific watcher relation. + """ + if not relation.app or not ( + secret_id := relation.data[relation.app].get("raft-secret-id") + ): + return None + + try: + secret = self.model.get_secret(id=secret_id) + content = secret.get_content(refresh=True) + return content.get("raft-password") + except SecretNotFoundError: + logger.warning(f"Secret {secret_id} not found") + return None + + def get_watcher_password(self, relation: Relation) -> str | None: + """Get the watcher PostgreSQL user password from the relation secret. + + Args: + relation: The specific watcher relation. + """ + if not relation.app or not ( + secret_id := relation.data[relation.app].get("raft-secret-id") + ): + return None + + try: + secret = self.model.get_secret(id=secret_id) + content = secret.get_content(refresh=True) + return content.get("watcher-password") + except SecretNotFoundError: + logger.warning(f"Secret {secret_id} not found") + return None + + def _get_raft_partner_addrs(self, relation: Relation) -> list[str]: + """Get Raft partner addresses from the relation. + + Args: + relation: The specific watcher relation. + """ + if not relation.app or not ( + raft_addrs_json := relation.data[relation.app].get("raft-partner-addrs") + ): + return [] + + try: + return json.loads(raft_addrs_json) + except json.JSONDecodeError: + logger.warning("Failed to parse raft-partner-addrs JSON") + return [] + + def _get_cluster_name(self, relation: Relation) -> str: + """Get the cluster name from the relation app data. + + Args: + relation: The specific watcher relation. + + Returns: + The cluster name, or a fallback label. + """ + if relation.app and (name := relation.data[relation.app].get("cluster-name")): + return name + return f"relation-{relation.id}" + + def _get_patroni_cas(self, relation: Relation) -> str | None: + if relation.app and (name := relation.data[relation.app].get("patroni-cas")): + return name + return f"relation-{relation.id}" + + def _get_standby_clusters(self, relation: Relation) -> list[str]: + """Get related standby clusters from the relation app data. + + Args: + relation: The specific watcher relation. + + Returns: + A list of standby cluster names. + """ + if not relation.app or not ( + standby_clusters_json := relation.data[relation.app].get("standby-clusters") + ): + return [] + + try: + return json.loads(standby_clusters_json) + except json.JSONDecodeError: + logger.warning("Failed to parse standby-clusters JSON") + return [] + + # -- Lifecycle events -- + + def _on_install(self, event: InstallEvent) -> None: + """Install prerequisites for the application.""" + logger.debug("Install start time: %s", datetime.now()) + + self.charm.set_unit_status(MaintenanceStatus("installing RAFT controller")) + + # Install the charmed PostgreSQL snap. + self.charm._install_snap_package(revision=None) + install_service() + + def _on_start(self, event: StartEvent) -> None: + """Handle start event in watcher mode.""" + if not self.is_related: + self.charm.unit.status = WaitingStatus("Waiting for relation to PostgreSQL") + return + # Don't set ActiveStatus here -- let _on_update_status promote to Active + # once Raft is actually connected + self.charm.unit.status = WaitingStatus("Starting Raft connection") + + def _on_leader_elected(self, _) -> None: + self._update_unit_address_if_changed() + + def _update_unit_address_if_changed(self) -> None: + """Update unit-address in relation data if IP has changed, for ALL relations.""" + if not (new_address := self.unit_ip): + return + + unit_az = os.environ.get("JUJU_AVAILABILITY_ZONE") + for relation in self.model.relations.get(WATCHER_RELATION, []): + current_address = relation.data[self.charm.unit].get("unit-address") + current_az = relation.data[self.charm.app].get("unit-az") + address_changed = current_address != new_address + az_changed = bool(unit_az and current_az != unit_az) + + if not address_changed and not az_changed: + continue + + if address_changed: + logger.info( + f"Unit IP changed from {current_address} to {new_address} " + f"in relation {relation.id}, updating relation data" + ) + relation.data[self.charm.unit]["unit-address"] = new_address + + if az_changed: + relation.data[self.charm.app]["unit-az"] = str(unit_az) + + if ( + address_changed + and (raft_password := self._get_raft_password(relation)) + and (partner_addrs := self._get_raft_partner_addrs(relation)) + ): + port = self._get_port_for_relation(relation.id) + raft_controller = RaftController(self.charm, f"rel{relation.id}") + changed = raft_controller.configure( + port, + new_address, + partner_addrs, + raft_password, + self._get_patroni_cas(relation), + ) + if changed and service_running(raft_controller.service_name): + logger.info( + f"Restarting Raft controller for relation {relation.id} due to IP change" + ) + raft_controller.restart() + for stale_addr in raft_controller.get_stale_watchers( + new_address, raft_password, partner_addrs, port + ): + raft_controller.remove_raft_member(stale_addr, raft_password, partner_addrs) + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Handle update status event in watcher mode.""" + if not self.charm.unit.is_leader(): + if self.charm._peers and len(self.charm._peers.units) > 0: + self.charm.unit.status = BlockedStatus("Multiple watcher units. One expected.") + event.defer() + return + + if not (relations := self.model.relations.get(WATCHER_RELATION, [])): + self.charm.unit.status = WaitingStatus("Waiting for relation to PostgreSQL") + return + + self._update_unit_address_if_changed() + + connected_count = 0 + disabled = False + total_endpoints = 0 + az_warnings: list[str] = [] + info_warnings: list[str] = [] + + for relation in relations: + port = self._get_port_for_relation(relation.id) + password = self._get_raft_password(relation) + raft_controller = RaftController(self.charm, instance_id=f"rel{relation.id}") + raft_status = raft_controller.get_status(port, password) + disabled = disabled or self._is_disabled(relation) + connected_count += 1 if raft_status.get("connected") else 0 + + pg_endpoints = self._get_raft_partner_addrs(relation) + total_endpoints += len(pg_endpoints) + partner_addrs = self._get_raft_partner_addrs(relation) + + if password and not self._should_watcher_vote(partner_addrs): + cluster_name = self._get_cluster_name(relation) + raft_controller.remove_raft_member( + f"{self.unit_ip}:{port}", password, pg_endpoints + ) + info_warnings.append( + f"WARNING: cluster '{cluster_name}' has odd number units;" + " adding a watcher creates even Raft membership," + " which degrades partition tolerance" + ) + raft_controller.remove_service() + disabled = True + + az_warning = self._check_az_colocation(relation) + if az_warning: + az_warnings.append(az_warning) + + if connected_count == 0 and not disabled: + self.charm.unit.status = WaitingStatus("Connecting to Raft cluster") + return + + cluster_count = len(relations) + msg = ( + f"Raft connected, monitoring {total_endpoints} PostgreSQL endpoints" + if cluster_count == 1 + else ( + f"Raft connected to {connected_count}/{cluster_count} clusters, " + f"monitoring {total_endpoints} PostgreSQL endpoints" + ) + ) + + # AZ co-location blocks in production; odd-count warnings never block + if az_warnings and self.charm.config.profile == "production": + self.charm.unit.status = BlockedStatus("AZ co-location: " + "; ".join(az_warnings)) + return + + if all_warnings := az_warnings + info_warnings: + msg += "; " + "; ".join(all_warnings) + + self.charm.unit.status = ActiveStatus(msg) + + def _check_az_colocation(self, relation: Relation) -> str | None: + """Check if the watcher is in the same AZ as any PostgreSQL unit. + + Args: + relation: The specific watcher relation. + + Returns: + Warning message if co-located, None otherwise. + """ + if not (watcher_az := os.environ.get("JUJU_AVAILABILITY_ZONE")): + return None + + colocated_units = [] + for unit in relation.units: + unit_az = relation.data[unit].get("unit-az") + if unit_az and unit_az == watcher_az: + colocated_units.append(unit.name) + + if colocated_units: + return f"WARNING: watcher shares AZ '{watcher_az}' with {', '.join(colocated_units)}" + return None + + # -- Relation events -- + + def _on_watcher_relation_joined(self, event: RelationJoinedEvent) -> None: + """Handle watcher relation joined event.""" + if not self.charm.unit.is_leader(): + if self.charm._peers and len(self.charm._peers.units) > 0: + self.charm.unit.status = BlockedStatus("Multiple watcher units. One expected.") + event.defer() + return + + logger.info(f"Joined watcher relation {event.relation.id} with PostgreSQL cluster") + if unit_ip := self.unit_ip: + event.relation.data[self.charm.unit]["unit-address"] = unit_ip + unit_az = os.environ.get("JUJU_AVAILABILITY_ZONE") + if unit_az: + event.relation.data[self.charm.app]["unit-az"] = unit_az + + def _should_watcher_vote(self, partner_addrs: list[str]) -> bool: + pg_num = len(partner_addrs) + return pg_num < 3 or pg_num % 2 == 0 + + def _on_watcher_relation_changed( + self, event: RelationChangedEvent | SecretChangedEvent + ) -> None: + """Handle watcher relation changed event.""" + if not self.charm.unit.is_leader(): + return + + if self.charm._peers is None or not (unit_ip := self.unit_ip): + logger.debug("Deferring watcher relation: Peer relation not yet joined") + event.defer() + return + + relations = ( + [event.relation] + if isinstance(event, RelationChangedEvent) + else self.model.relations.get(WATCHER_RELATION, []) + ) + for relation in relations: + logger.info(f"Watcher relation {relation.id} data changed") + + if not (raft_password := self._get_raft_password(relation)) or not ( + partner_addrs := self._get_raft_partner_addrs(relation) + ): + logger.debug("Raft details are not yet available") + return + + # Get or assign a port for this relation + port = self._get_port_for_relation(relation.id) + + raft_controller = RaftController(self.charm, f"rel{relation.id}") + if self._is_disabled(relation) or not self._should_watcher_vote(partner_addrs): + logger.debug("Disabling the watcher") + raft_controller.remove_service() + raft_controller.remove_raft_member( + f"{self.unit_ip}:{port}", raft_password, partner_addrs + ) + relation.data[self.charm.app]["raft-status"] = "disabled" + return + + if raft_controller.configure( + port, unit_ip, partner_addrs, raft_password, self._get_patroni_cas(relation) + ): + logger.info( + f"Restarting Raft controller for relation {relation.id} to apply config changes" + ) + raft_controller.restart() + + relation.data[self.charm.unit]["unit-address"] = unit_ip + relation.data[self.charm.app]["watcher-raft-port"] = str(port) + if unit_az := os.environ.get("JUJU_AVAILABILITY_ZONE"): + relation.data[self.charm.app]["unit-az"] = unit_az + # Only set raft-status and ActiveStatus after verifying the service is running + if service_running(raft_controller.service_name): + relation.data[self.charm.app]["raft-status"] = "connected" + # Check AZ co-location and enforce based on profile + if ( + az_warning := self._check_az_colocation(relation) + ) and self.charm.config.profile == "production": + self.charm.unit.status = BlockedStatus(f"AZ co-location: {az_warning}") + else: + self.charm.unit.status = ActiveStatus() + else: + self.charm.unit.status = WaitingStatus("Raft controller not running") + + def _on_watcher_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handle watcher relation broken event.""" + relation_id = event.relation.id + logger.info(f"Watcher relation {relation_id} broken") + + # Stop and clean up the Raft controller for this relation + controller = RaftController(self.charm, instance_id=f"rel{relation_id}") + controller.remove_service() + + # Release the port allocation + self._release_port_for_relation(relation_id) + + # Check if any relations remain + remaining = [ + r for r in self.model.relations.get(WATCHER_RELATION, []) if r.id != relation_id + ] + if not remaining: + self.charm.unit.status = WaitingStatus("Waiting for relation to PostgreSQL") diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..a3f1e1d --- /dev/null +++ b/src/utils.py @@ -0,0 +1,106 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""A collection of utility functions that are used in the charm.""" + +import os +import pwd +from asyncio import as_completed, create_task, run, wait +from contextlib import suppress +from ssl import CERT_NONE, create_default_context +from typing import Any + +from httpx import AsyncClient, BasicAuth, HTTPError + +from constants import API_REQUEST_TIMEOUT + + +def render_file(path: str, content: str, mode: int, change_owner: bool = True) -> None: + """Write a content rendered from a template to a file. + + Args: + path: the path to the file. + content: the data to be written to the file. + mode: access permission mask applied to the + file using chmod (e.g. 0o640). + change_owner: whether to change the file owner + to the _daemon_ user. + """ + # TODO: keep this method to use it also for generating replication configuration files and + # move it to an utils / helpers file. + # Write the content to the file. + with open(path, "w+") as file: + file.write(content) + # Ensure correct permissions are set on the file. + os.chmod(path, mode) + if change_owner: + _change_owner(path) + + +def create_directory(path: str, mode: int) -> None: + """Creates a directory. + + Args: + path: the path of the directory that should be created. + mode: access permission mask applied to the + directory using chmod (e.g. 0o640). + """ + os.makedirs(path, mode=mode, exist_ok=True) + # Ensure correct permissions are set on the directory. + os.chmod(path, mode) + _change_owner(path) + + +def _change_owner(path: str) -> None: + """Change the ownership of a file or a directory to the postgres user. + + Args: + path: path to a file or directory. + """ + # Get the uid/gid for the _daemon_ user. + user_database = pwd.getpwnam("_daemon_") + # Set the correct ownership for the file or directory. + os.chown(path, uid=user_database.pw_uid, gid=user_database.pw_gid) + + +async def _httpx_get_request( + url: str, cafile: str, auth: BasicAuth | None = None, verify: bool = True +) -> dict[str, Any] | None: + ssl_ctx = create_default_context() + if verify: + with suppress(FileNotFoundError): + ssl_ctx.load_verify_locations(cafile=cafile) + else: + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = CERT_NONE + async with AsyncClient(auth=auth, timeout=API_REQUEST_TIMEOUT, verify=ssl_ctx) as client: + try: + return (await client.get(url)).raise_for_status().json() + except (HTTPError, ValueError): + return None + + +async def _async_get_request( + uri: str, endpoints: list[str], cafile: str, auth: BasicAuth | None, verify: bool = True +) -> dict[str, Any] | None: + tasks = [ + create_task(_httpx_get_request(f"https://{ip}:8008{uri}", cafile, auth, verify)) + for ip in endpoints + ] + for task in as_completed(tasks): + if result := await task: + for task in tasks: + task.cancel() + await wait(tasks) + return result + + +def parallel_patroni_get_request( + uri: str, + endpoints: list[str], + cafile: str, + auth: BasicAuth | None = None, + verify: bool = True, +) -> dict[str, Any] | None: + """Call all possible patroni endpoints in parallel.""" + return run(_async_get_request(uri, endpoints, cafile, auth, verify)) diff --git a/templates/watcher.service.j2 b/templates/watcher.service.j2 new file mode 100644 index 0000000..2df0372 --- /dev/null +++ b/templates/watcher.service.j2 @@ -0,0 +1,19 @@ +[Unit] +Description=PostgreSQL Watcher Raft Service (%i) +After=network.target +Wants=network.target + +[Service] +Type=simple +# charmed-postgresql.patroni-raft-controller app lacks network interfaces +# in the snap profile, so run the controller under the patroni app profile. +ExecStart=/snap/bin/charmed-postgresql.patroni-raft-controller {{ config_file }}/%i/patroni-raft.yaml +Restart=always +RestartSec=5 +TimeoutStartSec=30 +TimeoutStopSec=30 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/templates/watcher.yml.j2 b/templates/watcher.yml.j2 new file mode 100644 index 0000000..a1708b2 --- /dev/null +++ b/templates/watcher.yml.j2 @@ -0,0 +1,18 @@ +######################################################################################### +# [ WARNING ] +# watcher configuration file maintained by the postgres-operator +# local changes may be overwritten. +######################################################################################### +# For a complete reference of all the options for this configuration file, +# please refer to https://patroni.readthedocs.io/en/latest/SETTINGS.html. + +raft: + {% if partner_addrs -%} + partner_addrs: + {% endif -%} + {% for partner_addr in partner_addrs -%} + - {{ partner_addr }}:2222 + {% endfor %} + self_addr: '{{ self_addr }}:{{ self_port }}' + password: {{ password }} + data_dir: {{ data_dir }}/raft diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/architecture.py b/tests/integration/architecture.py new file mode 100644 index 0000000..b0fbb34 --- /dev/null +++ b/tests/integration/architecture.py @@ -0,0 +1,7 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +import subprocess + +architecture = subprocess.run( + ["dpkg", "--print-architecture"], capture_output=True, check=True, encoding="utf-8" +).stdout.strip() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..580a011 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,17 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import logging + +import pytest + +from . import architecture + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def charm(): + # Return str instead of pathlib.Path since python-libjuju's model.deploy(), juju deploy, and + # juju bundle files expect local charms to begin with `./` or `/` to distinguish them from + # Charmhub charms. + return f"./postgresql-watcher_ubuntu@24.04-{architecture.architecture}.charm" diff --git a/tests/integration/ha_tests/__init__.py b/tests/integration/ha_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/ha_tests/conftest.py b/tests/integration/ha_tests/conftest.py new file mode 100644 index 0000000..398aa95 --- /dev/null +++ b/tests/integration/ha_tests/conftest.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +import pytest as pytest +from pytest_operator.plugin import OpsTest +from tenacity import Retrying, stop_after_delay, wait_fixed + +from .helpers import APPLICATION_NAME + + +@pytest.fixture() +async def continuous_writes(ops_test: OpsTest) -> None: + """Deploy the charm that makes continuous writes to PostgreSQL.""" + yield + # Clear the written data at the end. + for attempt in Retrying(stop=stop_after_delay(60 * 5), wait=wait_fixed(3), reraise=True): + with attempt: + action = ( + await ops_test.model + .applications[APPLICATION_NAME] + .units[0] + .run_action("clear-continuous-writes") + ) + await action.wait() + assert action.results["result"] == "True", "Unable to clear up continuous_writes table" diff --git a/tests/integration/ha_tests/helpers.py b/tests/integration/ha_tests/helpers.py new file mode 100644 index 0000000..2cec213 --- /dev/null +++ b/tests/integration/ha_tests/helpers.py @@ -0,0 +1,421 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import contextlib +import logging +import subprocess +from pathlib import Path + +import psycopg2 +import requests +import yaml +from juju.model import Model +from pytest_operator.plugin import OpsTest +from tenacity import ( + RetryError, + Retrying, + retry, + stop_after_attempt, + stop_after_delay, + wait_fixed, +) + +from ..helpers import ( + APPLICATION_NAME, + get_password, + get_patroni_cluster, + get_unit_address, +) + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +PORT = 5432 +APP_NAME = METADATA["name"] +SERVICE_NAME = "snap.charmed-postgresql.patroni.service" +PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{SERVICE_NAME}" +RESTART_CONDITION = "no" +ORIGINAL_RESTART_CONDITION = "always" + + +class MemberNotListedOnClusterError(Exception): + """Raised when a member is not listed in the cluster.""" + + +class MemberNotUpdatedOnClusterError(Exception): + """Raised when a member is not yet updated in the cluster.""" + + +class ProcessError(Exception): + """Raised when a process fails.""" + + +class ProcessRunningError(Exception): + """Raised when a process is running when it is not expected to be.""" + + +async def are_writes_increasing( + ops_test, + down_unit: str | None = None, + use_ip_from_inside: bool = False, + extra_model: Model = None, +) -> None: + """Verify new writes are continuing by counting the number of writes.""" + down_units = [down_unit] if isinstance(down_unit, str) or not down_unit else down_unit + writes, _ = await count_writes( + ops_test, + down_unit=down_units[0], + use_ip_from_inside=use_ip_from_inside, + extra_model=extra_model, + ) + logger.info(f"Initial writes {writes}") + + for attempt in Retrying(stop=stop_after_delay(60 * 3), wait=wait_fixed(3), reraise=True): + with attempt: + more_writes, _ = await count_writes( + ops_test, + down_unit=down_units[0], + use_ip_from_inside=use_ip_from_inside, + extra_model=extra_model, + ) + logger.info(f"Retry writes {more_writes}") + for member, count in writes.items(): + if "/".join(member.split(".", 1)[-1].rsplit("-", 1)) in down_units: + continue + assert more_writes[member] > count, ( + f"{member}: writes not continuing to DB (current writes: {more_writes[member]} - previous writes: {count})" + ) + + +async def app_name( + ops_test: OpsTest, application_name: str = "postgresql", model: Model = None +) -> str | None: + """Returns the name of the cluster running PostgreSQL. + + This is important since not all deployments of the PostgreSQL charm have the application name + "postgresql". + + Note: if multiple clusters are running PostgreSQL this will return the one first found. + """ + if model is None: + model = ops_test.model + status = await model.get_status() + for app in model.applications: + if ( + application_name in status["applications"][app]["charm"] + and APPLICATION_NAME not in status["applications"][app]["charm"] + ): + return app + + return None + + +async def check_writes( + ops_test, use_ip_from_inside: bool = False, extra_model: Model = None +) -> int: + """Gets the total writes from the test charm and compares to the writes from db.""" + total_expected_writes = await stop_continuous_writes(ops_test) + for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(5), reraise=True): + with attempt: + actual_writes, max_number_written = await count_writes( + ops_test, use_ip_from_inside=use_ip_from_inside, extra_model=extra_model + ) + for member, count in actual_writes.items(): + logger.info( + f"member: {member}, count: {count}, max_number_written: {max_number_written[member]}, total_expected_writes: {total_expected_writes}" + ) + assert count == max_number_written[member], ( + f"{member}: writes to the db were missed: count of actual writes different from the max number written." + ) + assert total_expected_writes == count, f"{member}: writes to the db were missed." + return total_expected_writes + + +async def count_writes( + ops_test: OpsTest, + down_unit: str | None = None, + use_ip_from_inside: bool = False, + extra_model: Model = None, +) -> tuple[dict[str, int], dict[str, int]]: + """Count the number of writes in the database.""" + app = await app_name(ops_test) + password = await get_password(ops_test, database_app_name=app) + members = [] + for model in [ops_test.model, extra_model]: + if model is None: + continue + for unit in model.applications[app].units: + if unit.name != down_unit: + members_data = get_patroni_cluster( + await ( + get_ip_from_inside_the_unit(ops_test, unit.name) + if use_ip_from_inside + else get_unit_ip(ops_test, unit.name) + ) + )["members"] + for member_data in members_data: + member_data["model"] = model.info.name + members.extend(members_data) + break + down_ips = [] + if down_unit: + for unit in ops_test.model.applications[app].units: + if unit.name == down_unit: + down_ips.append(unit.public_address) + down_ips.append(await get_unit_ip(ops_test, unit.name)) + return count_writes_on_members(members, password, down_ips) + + +def count_writes_on_members(members, password, down_ips) -> tuple[dict[str, int], dict[str, int]]: + count = {} + maximum = {} + for member in members: + if member["role"] != "replica" and member["host"] not in down_ips: + host = member["host"] + + connection_string = ( + f"dbname='{APPLICATION_NAME.replace('-', '_')}_database' user='operator'" + f" host='{host}' password='{password}' connect_timeout=10" + ) + + member_name = f"{member['model']}.{member['name']}" + connection = None + try: + with ( + psycopg2.connect(connection_string) as connection, + connection.cursor() as cursor, + ): + cursor.execute("SELECT COUNT(number), MAX(number) FROM continuous_writes;") + results = cursor.fetchone() + count[member_name] = results[0] + maximum[member_name] = results[1] + except psycopg2.Error: + # Error raised when the connection is not possible. + count[member_name] = -1 + maximum[member_name] = -1 + finally: + if connection is not None: + connection.close() + return count, maximum + + +def cut_network_from_unit(machine_name: str) -> None: + """Cut network from a lxc container. + + Args: + machine_name: lxc container hostname + """ + # apply a mask (device type `none`) + cut_network_command = f"lxc config device add {machine_name} eth0 none" + subprocess.check_call(cut_network_command.split()) + + +def cut_network_from_unit_without_ip_change(machine_name: str) -> None: + """Cut network from a lxc container (without causing the change of the unit IP address). + + Args: + machine_name: lxc container hostname + """ + override_command = f"lxc config device override {machine_name} eth0" + # Ignore if the interface was already overridden. + with contextlib.suppress(subprocess.CalledProcessError): + subprocess.check_call(override_command.split()) + limit_set_command = f"lxc config device set {machine_name} eth0 limits.egress=0kbit" + subprocess.check_call(limit_set_command.split()) + limit_set_command = f"lxc config device set {machine_name} eth0 limits.ingress=1kbit" + subprocess.check_call(limit_set_command.split()) + limit_set_command = f"lxc config device set {machine_name} eth0 limits.priority=10" + subprocess.check_call(limit_set_command.split()) + + +async def get_ip_from_inside_the_unit(ops_test: OpsTest, unit_name: str) -> str: + command = f"exec --unit {unit_name} -- hostname -I" + return_code, stdout, stderr = await ops_test.juju(*command.split()) + if return_code != 0: + raise ProcessError( + "Expected command %s to succeed instead it failed: %s %s", command, return_code, stderr + ) + return stdout.splitlines()[0].strip() + + +async def get_unit_ip(ops_test: OpsTest, unit_name: str, model: Model = None) -> str: + """Wrapper for getting unit ip. + + Args: + ops_test: The ops test object passed into every test case + unit_name: The name of the unit to get the address + model: Optional model instance to use + Returns: + The (str) ip of the unit + """ + if model is None: + application = unit_name.split("/")[0] + for unit in ops_test.model.applications[application].units: + if unit.name == unit_name: + break + return await instance_ip(ops_test, unit.machine.hostname) + else: + return get_unit_address(ops_test, unit_name) + + +async def get_cluster_roles( + ops_test: OpsTest, unit_name: str, use_ip_from_inside: bool = False +) -> dict[str, str | list[str] | None]: + """Returns whether the unit a replica in the cluster.""" + unit_ip = await ( + get_ip_from_inside_the_unit(ops_test, unit_name) + if use_ip_from_inside + else get_unit_ip(ops_test, unit_name) + ) + + members = {"replicas": [], "primaries": [], "sync_standbys": []} + cluster_info = requests.get(f"https://{unit_ip}:8008/cluster", verify=False) + member_list = cluster_info.json()["members"] + logger.info(f"Cluster members are: {member_list}") + for member in member_list: + role = member["role"] + name = "/".join(member["name"].rsplit("-", 1)) + if role == "leader": + members["primaries"].append(name) + elif role == "sync_standby": + members["sync_standbys"].append(name) + else: + members["replicas"].append(name) + + return members + + +async def instance_ip(ops_test: OpsTest, instance: str) -> str: + """Translate juju instance name to IP. + + Args: + ops_test: pytest ops test helper + instance: The name of the instance + + Returns: + The (str) IP address of the instance + """ + _, output, _ = await ops_test.juju("machines") + + for line in output.splitlines(): + if instance in line: + return line.split()[2] + + +async def get_primary(ops_test: OpsTest, app, down_unit: str | None = None) -> str: + """Use the charm action to retrieve the primary from provided application. + + Args: + ops_test: OpsTest instance. + app: database application name. + down_unit: unit that is offline and the action won't run on. + + Returns: + primary unit name. + """ + for unit in ops_test.model.applications[app].units: + if unit.name != down_unit: + action = await unit.run_action("get-primary") + action = await action.wait() + primary = action.results.get("primary", "None") + if primary == "None": + continue + return primary + return None + + +async def is_postgresql_ready(ops_test, unit_name: str, use_ip_from_inside: bool = False) -> bool: + """Verifies a PostgreSQL instance is running and available.""" + unit_ip = ( + (await get_ip_from_inside_the_unit(ops_test, unit_name)) + if use_ip_from_inside + else get_unit_address(ops_test, unit_name) + ) + try: + for attempt in Retrying(stop=stop_after_delay(60 * 5), wait=wait_fixed(3)): + with attempt: + instance_health_info = requests.get(f"https://{unit_ip}:8008/health", verify=False) + assert instance_health_info.status_code == 200 + except RetryError: + return False + + return True + + +def restore_network_for_unit(machine_name: str) -> None: + """Restore network from a lxc container. + + Args: + machine_name: lxc container hostname + """ + # remove mask from eth0 + restore_network_command = f"lxc config device remove {machine_name} eth0" + subprocess.check_call(restore_network_command.split()) + + +def restore_network_for_unit_without_ip_change(machine_name: str) -> None: + """Restore network from a lxc container (without causing the change of the unit IP address). + + Args: + machine_name: lxc container hostname + """ + limit_set_command = f"lxc config device set {machine_name} eth0 limits.egress=" + subprocess.check_call(limit_set_command.split()) + limit_set_command = f"lxc config device set {machine_name} eth0 limits.ingress=" + subprocess.check_call(limit_set_command.split()) + limit_set_command = f"lxc config device set {machine_name} eth0 limits.priority=" + subprocess.check_call(limit_set_command.split()) + + +async def start_continuous_writes(ops_test: OpsTest, app: str, model: Model = None) -> None: + """Start continuous writes to PostgreSQL.""" + # Start the process by relating the application to the database or + # by calling the action if the relation already exists. + if model is None: + model = ops_test.model + relations = [ + relation + for relation in model.applications[app].relations + if not relation.is_peer + and f"{relation.requires.application_name}:{relation.requires.name}" + == f"{APPLICATION_NAME}:database" + ] + if not relations: + await model.relate(app, f"{APPLICATION_NAME}:database") + await model.wait_for_idle(status="active", timeout=1000) + for attempt in Retrying(stop=stop_after_delay(60 * 5), wait=wait_fixed(3), reraise=True): + with attempt: + action = ( + await model + .applications[APPLICATION_NAME] + .units[0] + .run_action("start-continuous-writes") + ) + await action.wait() + assert action.results["result"] == "True", "Unable to create continuous_writes table" + + +async def stop_continuous_writes(ops_test: OpsTest) -> int: + """Stops continuous writes to PostgreSQL and returns the last written value.""" + action = ( + await ops_test.model + .applications[APPLICATION_NAME] + .units[0] + .run_action("stop-continuous-writes") + ) + action = await action.wait() + return int(action.results["writes"]) + + +@retry(stop=stop_after_attempt(20), wait=wait_fixed(30)) +async def wait_network_restore(ops_test: OpsTest, unit_name: str, old_ip: str) -> None: + """Wait until network is restored. + + Args: + ops_test: pytest plugin helper + unit_name: name of the unit + old_ip: old registered IP address + """ + # Retrieve the unit IP from inside the unit because it may not be updated in the + # Juju status too quickly. + if (await get_ip_from_inside_the_unit(ops_test, unit_name)) == old_ip: + raise Exception diff --git a/tests/integration/ha_tests/test_stereo_mode.py b/tests/integration/ha_tests/test_stereo_mode.py new file mode 100644 index 0000000..747b6a1 --- /dev/null +++ b/tests/integration/ha_tests/test_stereo_mode.py @@ -0,0 +1,869 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for PostgreSQL stereo mode with watcher. + +Tests the deployment and failover scenarios for 2-node PostgreSQL clusters +with a watcher/witness node for quorum. + +Test scenarios from acceptance criteria: +1. Replica shutdown: clients rerouted to primary, no significant outage +2. Primary shutdown: replica promoted, old primary becomes replica when healthy +3. Watcher shutdown: no service outage +4. Network isolation variants of above +""" + +import asyncio +import logging + +import pytest +from pytest_operator.plugin import OpsTest +from tenacity import Retrying, stop_after_delay, wait_fixed +from yaml import safe_load + +from ..helpers import APPLICATION_NAME, DATABASE_APP_NAME +from .helpers import APPLICATION_NAME as TEST_APP_NAME +from .helpers import ( + are_writes_increasing, + check_writes, + cut_network_from_unit, + cut_network_from_unit_without_ip_change, + get_cluster_roles, + get_primary, + restore_network_for_unit, + restore_network_for_unit_without_ip_change, +) + + +async def start_writes(ops_test: OpsTest) -> None: + """Start continuous writes to PostgreSQL (assumes relation already exists).""" + for attempt in Retrying(stop=stop_after_delay(60 * 5), wait=wait_fixed(3), reraise=True): + with attempt: + action = ( + await ops_test.model + .applications[TEST_APP_NAME] + .units[0] + .run_action("start-continuous-writes") + ) + await action.wait() + assert action.results["result"] == "True", "Unable to create continuous_writes table" + + +logger = logging.getLogger(__name__) + + +async def verify_raft_cluster_health( + ops_test: OpsTest, + db_app_name: str, + watcher_app_name: str, + expected_members: int = 3, + check_watcher_ip: bool = True, +) -> None: + """Verify that the Raft cluster has the expected number of members and quorum. + + This function checks that all PostgreSQL units see the expected number of + Raft members (including the watcher) and have quorum. This is critical + after watcher re-deployment to ensure the cluster is properly formed. + + Args: + ops_test: The OpsTest instance. + db_app_name: The PostgreSQL application name. + watcher_app_name: The watcher application name. + expected_members: Expected number of Raft members (default 3 for stereo mode). + check_watcher_ip: Whether to verify the watcher IP in Raft status (default True). + Set to False after network isolation tests where watcher may have been + redeployed with a new IP that isn't yet in the Raft configuration. + + Raises: + AssertionError: If the Raft cluster is not healthy. + """ + logger.info(f"Verifying Raft cluster health with {expected_members} expected members") + + # Get watcher address for verification using juju exec to avoid cached IPs + watcher_unit = ops_test.model.applications[watcher_app_name].units[0] + return_code, watcher_ip, _ = await ops_test.juju( + "exec", "--unit", watcher_unit.name, "--", "unit-get", "private-address" + ) + assert return_code == 0, f"Failed to get watcher address from {watcher_unit.name}" + watcher_ip = watcher_ip.strip() + + for attempt in Retrying(stop=stop_after_delay(360), wait=wait_fixed(10), reraise=True): + with attempt: + for unit in ops_test.model.applications[db_app_name].units: + # Get the Raft password from Patroni config using juju exec directly + # We need to avoid shell interpretation issues with run_command_on_unit + complete_command = [ + "exec", + "--unit", + unit.name, + "--", + "cat", + "/var/snap/charmed-postgresql/current/etc/patroni/patroni.yaml", + ] + return_code, stdout, _ = await ops_test.juju(*complete_command) + assert return_code == 0, f"Failed to read patroni.yaml on {unit.name}" + + conf = safe_load(stdout) + password = conf.get("raft", {}).get("password") + assert password, f"Could not find Raft password in patroni.yaml on {unit.name}" + + # Check Raft status using the password via juju exec directly + complete_command = [ + "exec", + "--unit", + unit.name, + "--", + "charmed-postgresql.syncobj-admin", + "-conn", + conf["raft"]["self_addr"], + "-pass", + password, + "-status", + ] + return_code, output, _ = await ops_test.juju(*complete_command) + if return_code != 0: + logger.warning(f"Raft status check failed on {unit.name}: {output}") + raise AssertionError(f"Raft status check failed on {unit.name}") + logger.info(f"Raft status on {unit.name}: {output[:200]}...") + + # Verify quorum + assert "has_quorum: True" in output or "has_quorum:True" in output, ( + f"Unit {unit.name} does not have Raft quorum" + ) + + # Verify watcher is in the cluster (if requested) + # After network isolation tests, the watcher may have been redeployed + # with a new IP that isn't yet updated in the Raft configuration + if check_watcher_ip: + assert watcher_ip in output, ( + f"Watcher {watcher_ip} not found in Raft cluster on {unit.name}\n" + f"Raft output: {output}" + ) + + logger.info("Raft cluster health verified successfully") + + +WATCHER_APP_NAME = "pg-watcher" + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy_stereo_mode(ops_test: OpsTest, charm) -> None: + """Build and deploy PostgreSQL in stereo mode with watcher. + + Deploys 2 PostgreSQL units and a watcher (same charm, role=watcher), + then relates them to form a 3-node Raft cluster for quorum. + """ + pytest.skip("Needs both sides of the relation") + return + # Check if PostgreSQL is already deployed (e.g., from a previous test run) + # If so, verify it's in the expected state or skip deployment + if DATABASE_APP_NAME in ops_test.model.applications: + logger.info("PostgreSQL already deployed, checking state...") + pg_units = len(ops_test.model.applications[DATABASE_APP_NAME].units) + watcher_deployed = WATCHER_APP_NAME in ops_test.model.applications + test_app_deployed = APPLICATION_NAME in ops_test.model.applications + + if pg_units == 2 and watcher_deployed and test_app_deployed: + logger.info("Stereo mode already deployed with expected state, verifying...") + await ops_test.model.wait_for_idle(status="active", timeout=300) + return + + # If state is incorrect, we need to clean up and redeploy + logger.info(f"Unexpected state (pg_units={pg_units}), cleaning up...") + for app in [DATABASE_APP_NAME, WATCHER_APP_NAME, APPLICATION_NAME]: + if app in ops_test.model.applications: + await ops_test.model.remove_application(app, block_until_done=True) + + async with ops_test.fast_forward(): + # Deploy PostgreSQL with 2 units from the start + logger.info("Deploying PostgreSQL charm with 2 units...") + await ops_test.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=2, + series="noble", + config={"profile": "testing", "synchronous-mode-strict": False}, + ) + # Deploy watcher using the same charm with role=watcher + logger.info("Deploying watcher (same charm, role=watcher)...") + await ops_test.model.deploy( + charm, + application_name=WATCHER_APP_NAME, + num_units=1, + series="noble", + config={"role": "watcher", "profile": "testing"}, + ) + logger.info("Deploying test application...") + await ops_test.model.deploy( + APPLICATION_NAME, + application_name=APPLICATION_NAME, + series="noble", + channel="edge", + ) + + # Wait for initial deployment + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME, WATCHER_APP_NAME], + timeout=1200, + raise_on_error=False, # Watcher may be waiting for relation + ) + + # Relate PostgreSQL (watcher-offer) to watcher (watcher) + # The relation may already exist if deploying into a model with prior state + logger.info("Relating PostgreSQL to watcher") + try: + await ops_test.model.integrate( + f"{DATABASE_APP_NAME}:watcher-offer", f"{WATCHER_APP_NAME}:watcher" + ) + except Exception as e: + if "already exists" in str(e) or "relation" in str(e).lower(): + logger.info(f"Watcher relation already exists: {e}") + else: + raise + + # Wait for watcher to join Raft cluster + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME, WATCHER_APP_NAME], + status="active", + timeout=600, + ) + + # Relate PostgreSQL to test app + try: + await ops_test.model.integrate(DATABASE_APP_NAME, f"{APPLICATION_NAME}:database") + except Exception as e: + if "already exists" in str(e) or "relation" in str(e).lower(): + logger.info(f"Database relation already exists: {e}") + else: + raise + + await ops_test.model.wait_for_idle(status="active", timeout=1800) + + # Verify deployment + assert len(ops_test.model.applications[DATABASE_APP_NAME].units) == 2 + assert len(ops_test.model.applications[WATCHER_APP_NAME].units) == 1 + + +@pytest.mark.abort_on_fail +# async def test_replica_shutdown_with_watcher(ops_test: OpsTest, continuous_writes) -> None: +async def test_replica_shutdown_with_watcher(ops_test: OpsTest) -> None: + """Test replica shutdown with watcher providing quorum. + + Expected behavior: + - All connected clients to the primary should not be interrupted + - Clients connected to replica should be re-routed to primary + - No significant outage (less than a minute) + """ + pytest.skip("Needs both sides of the relation") + return + await start_writes(ops_test) + + # Get current cluster roles + any_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + original_roles = await get_cluster_roles(ops_test, any_unit) + primary = original_roles["primaries"][0] + + # Get the replica unit + replica = None + for unit in ops_test.model.applications[DATABASE_APP_NAME].units: + if unit.name != primary: + replica = unit.name + break + + assert replica is not None, "Could not find replica unit" + logger.info(f"Shutting down replica: {replica}") + + # Shutdown the replica + await ops_test.model.destroy_unit(replica, force=True, destroy_storage=False, max_wait=1500) + + # Wait for the cluster to stabilize after unit removal + # The primary needs time to reconfigure the cluster and update secrets + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + timeout=300, + idle_period=30, + ) + + # Verify writes continue (primary should still be available) + # With watcher, we should maintain quorum + await are_writes_increasing(ops_test, down_unit=replica) + + # Wait for cluster to stabilize + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + timeout=600, + idle_period=30, + ) + + # Scale back up + logger.info("Scaling back up after replica shutdown") + await ops_test.model.applications[DATABASE_APP_NAME].add_unit(count=1) + await ops_test.model.wait_for_idle(status="active", timeout=1500) + + # Wait for the new replica to become a sync_standby + # This ensures the cluster is fully ready for the next test + for attempt in Retrying(stop=stop_after_delay(180), wait=wait_fixed(10), reraise=True): + with attempt: + new_roles = await get_cluster_roles( + ops_test, ops_test.model.applications[DATABASE_APP_NAME].units[0].name + ) + logger.info(f"Cluster roles: {new_roles}") + assert len(new_roles["primaries"]) == 1, "Should have exactly one primary" + assert new_roles["primaries"][0] == primary, "Primary should not have changed" + assert len(new_roles["sync_standbys"]) == 1, "New replica should become sync_standby" + + await check_writes(ops_test) + + +@pytest.mark.abort_on_fail +# async def test_primary_shutdown_with_watcher(ops_test: OpsTest, continuous_writes) -> None: +async def test_primary_shutdown_with_watcher(ops_test: OpsTest) -> None: + """Test primary shutdown with watcher providing quorum. + + Expected behavior: + - Old primary should be network-isolated (Patroni handles this) + - Replica should be promoted to primary + - Clients re-routed to new primary + - When old primary is healthy, it should become a replica + """ + pytest.skip("Needs both sides of the relation") + return + await start_writes(ops_test) + + # Get current cluster roles + any_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + original_roles = await get_cluster_roles(ops_test, any_unit) + original_primary = original_roles["primaries"][0] + + # Get the replica - prefer sync_standby if available, otherwise any replica + # After a previous test scales up, the new unit may not yet be a sync_standby + if original_roles["sync_standbys"]: + original_replica = original_roles["sync_standbys"][0] + elif original_roles["replicas"]: + original_replica = original_roles["replicas"][0] + else: + # Fall back to finding the other unit manually + original_replica = None + for unit in ops_test.model.applications[DATABASE_APP_NAME].units: + if unit.name != original_primary: + original_replica = unit.name + break + assert original_replica is not None, "Could not find replica unit" + + logger.info(f"Shutting down primary: {original_primary}") + + # Shutdown the primary + await ops_test.model.destroy_unit( + original_primary, force=True, destroy_storage=False, max_wait=1500 + ) + + # With watcher providing quorum, failover should happen automatically + # Wait for the model to stabilize first + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + timeout=600, + idle_period=30, + ) + + # Wait for the replica to be promoted to primary + # Patroni needs time to detect leader failure and elect new leader (30-90s) + remaining_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + for attempt in Retrying(stop=stop_after_delay(180), wait=wait_fixed(10), reraise=True): + with attempt: + new_roles = await get_cluster_roles(ops_test, remaining_unit) + logger.info(f"Waiting for failover - current roles: {new_roles}") + assert len(new_roles["primaries"]) == 1, "Should have exactly one primary" + assert new_roles["primaries"][0] == original_replica, ( + f"Replica {original_replica} should have been promoted, " + f"but primary is {new_roles['primaries'][0]}" + ) + + # Wait for the charm to reconfigure after failover + # This ensures the relation endpoints are updated for the test app to reconnect + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + timeout=300, + idle_period=30, + ) + + # Scale back up FIRST - with synchronous_mode_strict=true, the primary cannot + # accept writes when there's no sync_standby available. We need 2 units before + # we can verify writes are working. + logger.info("Scaling back up after primary shutdown") + await ops_test.model.applications[DATABASE_APP_NAME].add_unit(count=1) + # Wait longer for the new unit to fully join the cluster + # The new unit needs to: start PostgreSQL, join Raft cluster, become sync_standby + await ops_test.model.wait_for_idle(status="active", timeout=1800, idle_period=60) + + # Wait for the new replica to become a sync_standby + # This can take a while as the new unit needs to fully sync and be recognized + for attempt in Retrying(stop=stop_after_delay(300), wait=wait_fixed(15), reraise=True): + with attempt: + final_roles = await get_cluster_roles( + ops_test, ops_test.model.applications[DATABASE_APP_NAME].units[0].name + ) + logger.info(f"Final cluster roles: {final_roles}") + assert len(final_roles["primaries"]) == 1, "Should have exactly one primary" + assert len(final_roles["sync_standbys"]) == 1, "New replica should become sync_standby" + + # Now that we have a sync_standby, restart continuous writes and verify + # The continuous writes app caches the connection string, so we need to clear + # and restart it after failover to pick up the new primary's address. + # First clear the old writes state + action = ( + await ops_test.model + .applications[TEST_APP_NAME] + .units[0] + .run_action("clear-continuous-writes") + ) + await action.wait() + + # Then start fresh writes + await start_writes(ops_test) + + # Verify writes continue on the new primary + await are_writes_increasing(ops_test, down_unit=original_primary) + + await check_writes(ops_test) + + +@pytest.mark.abort_on_fail +# async def test_watcher_shutdown_no_outage(ops_test: OpsTest, continuous_writes) -> None: +async def test_watcher_shutdown_no_outage(ops_test: OpsTest) -> None: + """Test watcher shutdown - should not cause service outage. + + Expected behavior: + - No outage experienced by either primary or replica + - Cluster continues to function (but loses quorum guarantee) + """ + pytest.skip("Needs both sides of the relation") + return + await start_writes(ops_test) + + # Get current cluster state + any_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + original_roles = await get_cluster_roles(ops_test, any_unit) + + logger.info("Removing watcher unit") + + # Remove the watcher + watcher_unit = ops_test.model.applications[WATCHER_APP_NAME].units[0] + await ops_test.model.destroy_unit(watcher_unit.name, force=True, max_wait=300) + + # Verify writes continue without interruption + await are_writes_increasing(ops_test) + + # PostgreSQL cluster should remain active + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + timeout=300, + idle_period=30, + ) + + # Verify cluster roles unchanged + new_roles = await get_cluster_roles(ops_test, any_unit) + assert new_roles["primaries"] == original_roles["primaries"] + + # Re-deploy watcher + logger.info("Re-deploying watcher") + await ops_test.model.applications[WATCHER_APP_NAME].add_unit(count=1) + await ops_test.model.wait_for_idle(status="active", timeout=600) + + # Verify the Raft cluster is properly formed with the new watcher + # This is critical - without this verification, subsequent tests might fail + # because the watcher is not actually participating in the Raft cluster + await verify_raft_cluster_health(ops_test, DATABASE_APP_NAME, WATCHER_APP_NAME) + + await check_writes(ops_test) + + +@pytest.mark.abort_on_fail +async def test_primary_network_isolation_with_watcher( + # ops_test: OpsTest, continuous_writes + ops_test: OpsTest, +) -> None: + """Test network isolation of primary with watcher. + + Expected behavior: + - Isolated primary's connections terminated + - Replica promoted to primary + - When network restored, old primary becomes replica + """ + pytest.skip("Needs both sides of the relation") + return + await start_writes(ops_test) + + # Get current cluster state + any_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + original_roles = await get_cluster_roles(ops_test, any_unit) + primary = original_roles["primaries"][0] + replica = original_roles["sync_standbys"][0] + + # Get primary machine name for network manipulation + primary_unit = None + for unit in ops_test.model.applications[DATABASE_APP_NAME].units: + if unit.name == primary: + primary_unit = unit + break + + assert primary_unit is not None + primary_machine = primary_unit.machine.hostname + + logger.info(f"Isolating primary network: {primary} on {primary_machine}") + + try: + # Cut network from primary (this removes the eth0 interface entirely) + cut_network_from_unit(primary_machine) + + # Wait for failover to happen - Patroni needs time to detect leader failure + # and elect a new leader. This can take 30-90 seconds depending on TTL settings. + # Use explicit retry loop instead of just wait_for_idle. + new_primary = None + for attempt in Retrying(stop=stop_after_delay(180), wait=wait_fixed(10), reraise=True): + with attempt: + new_primary = await get_primary(ops_test, DATABASE_APP_NAME, down_unit=primary) + logger.info(f"Current primary: {new_primary}, expected: {replica}") + assert new_primary == replica, ( + f"Waiting for failover: replica {replica} should be promoted, " + f"but primary is still {new_primary}" + ) + await are_writes_increasing(ops_test, down_unit=primary_unit.name) + finally: + # Restore network + logger.info(f"Restoring network for {primary_machine}") + restore_network_for_unit(primary_machine) + + # Wait for cluster to stabilize with restored network + # The old primary may take time to rejoin after getting a new IP address, + # so we use raise_on_error=False and wait longer + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + timeout=900, + idle_period=30, + raise_on_error=False, # Old primary may be in error while rejoining + ) + + # Wait for the old primary to rejoin as replica + # This can take a while as it needs to recover with a new IP + for attempt in Retrying(stop=stop_after_delay(300), wait=wait_fixed(15), reraise=True): + with attempt: + final_roles = await get_cluster_roles(ops_test, replica) + logger.info(f"Final cluster roles: {final_roles}") + assert replica in final_roles["primaries"], ( + "Replica should remain primary after network restore" + ) + # Old primary should not be primary anymore + assert ( + primary not in final_roles["primaries"] and primary in final_roles["sync_standbys"] + ), "Old primary should now be a sync standby" + + # Use use_ip_from_inside=True because the old primary got a new IP after network restore + # and Juju's cached IP may be stale + await check_writes(ops_test, use_ip_from_inside=True) + + +@pytest.mark.abort_on_fail +async def test_replica_network_isolation_with_watcher( + # ops_test: OpsTest, continuous_writes + ops_test: OpsTest, +) -> None: + """Test network isolation of replica with watcher. + + Expected behavior: + - Primary remains primary (doesn't failover) - Raft quorum maintained with watcher + - With synchronous_mode_strict=true, writes pause (no sync_standby available) + - After network restore, writes resume + - No data loss + + Note: This test uses iptables-based network isolation to preserve the replica's IP, + avoiding the complexity of IP changes when using eth0 device removal. + """ + pytest.skip("Needs both sides of the relation") + return + await start_writes(ops_test) + + # Get current cluster state - use use_ip_from_inside=True because the previous test + # may have left units with stale IPs in Juju's cache after network restore + any_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + original_roles = await get_cluster_roles(ops_test, any_unit, use_ip_from_inside=True) + primary = original_roles["primaries"][0] + replica = original_roles["sync_standbys"][0] + + # Get replica machine for network manipulation + replica_unit = None + for unit in ops_test.model.applications[DATABASE_APP_NAME].units: + if unit.name == replica: + replica_unit = unit + break + + assert replica_unit is not None + replica_machine = replica_unit.machine.hostname + + logger.info(f"Isolating replica network: {replica} on {replica_machine}") + + try: + # Cut network from replica using iptables (preserves IP) + cut_network_from_unit_without_ip_change(replica_machine) + + # Give Patroni time to detect the network isolation. + await asyncio.sleep(30) + + # Primary should remain primary (no failover should happen) + # Raft quorum is maintained with primary + watcher (2 out of 3) + current_primary = await get_primary(ops_test, DATABASE_APP_NAME, down_unit=replica) + assert current_primary == primary, "Primary should not change during replica isolation" + await are_writes_increasing(ops_test, down_unit=replica) + finally: + # Restore network + logger.info(f"Restoring network for {replica_machine}") + restore_network_for_unit_without_ip_change(replica_machine) + + # Wait for cluster to stabilize - replica should rejoin + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + timeout=600, + idle_period=30, + ) + + # Verify cluster has a primary after restore (may or may not be the same one, + # since Patroni can switchover during network restore/rejoin) + final_roles = await get_cluster_roles(ops_test, any_unit, use_ip_from_inside=True) + assert len(final_roles["primaries"]) == 1, ( + "Cluster should have exactly one primary after restore" + ) + + # Verify writes continue after network restore + # Use use_ip_from_inside=True because previous tests may have caused IP changes + await are_writes_increasing(ops_test, use_ip_from_inside=True) + await check_writes(ops_test, use_ip_from_inside=True) + + +@pytest.mark.abort_on_fail +# async def test_watcher_network_isolation(ops_test: OpsTest, continuous_writes) -> None: +async def test_watcher_network_isolation(ops_test: OpsTest) -> None: + """Test network isolation of watcher. + + Expected behavior: + - No service outage for PostgreSQL cluster + - Cluster loses quorum guarantee but continues operating + """ + pytest.skip("Needs both sides of the relation") + return + await start_writes(ops_test) + + # Get watcher machine + watcher_unit = ops_test.model.applications[WATCHER_APP_NAME].units[0] + watcher_machine = watcher_unit.machine.hostname + + # Get current cluster state - use use_ip_from_inside=True because previous tests + # may have left units with stale IPs in Juju's cache after network manipulation + any_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + original_roles = await get_cluster_roles(ops_test, any_unit, use_ip_from_inside=True) + + logger.info(f"Isolating watcher network: {watcher_machine}") + + try: + # Cut network from watcher + cut_network_from_unit(watcher_machine) + + # Verify writes continue without interruption + await are_writes_increasing(ops_test, use_ip_from_inside=True) + + # Cluster roles should remain unchanged + current_roles = await get_cluster_roles(ops_test, any_unit, use_ip_from_inside=True) + assert current_roles["primaries"] == original_roles["primaries"] + + finally: + # Restore network + logger.info(f"Restoring watcher network: {watcher_machine}") + restore_network_for_unit(watcher_machine) + + # Wait for full recovery + await ops_test.model.wait_for_idle(status="active", timeout=600) + + # Use use_ip_from_inside=True because the watcher got a new IP after network restore + await check_writes(ops_test, use_ip_from_inside=True) + + +@pytest.mark.abort_on_fail +async def test_multi_cluster_watcher(ops_test: OpsTest, charm) -> None: + """Verify that a single watcher can monitor multiple PostgreSQL clusters. + + The watcher relation no longer has limit: 1, so the watcher can relate + to multiple PostgreSQL clusters simultaneously. Each relation gets its own + Raft instance with a dedicated port and data directory. + """ + pytest.skip("Needs both sides of the relation") + return + second_pg_app = "postgresql-b" + + try: + # Deploy a second PostgreSQL cluster + logger.info("Deploying second PostgreSQL cluster for multi-cluster watcher test") + await ops_test.model.deploy( + charm, + application_name=second_pg_app, + num_units=2, + series="noble", + config={"profile": "testing", "synchronous-mode-strict": False}, + ) + await ops_test.model.wait_for_idle( + apps=[second_pg_app], + status="active", + timeout=1200, + ) + + # Relate the watcher to the second cluster + logger.info("Relating watcher to second PostgreSQL cluster") + await ops_test.model.integrate( + f"{second_pg_app}:watcher-offer", f"{WATCHER_APP_NAME}:watcher" + ) + + # Use fast_forward to trigger update_status quickly, which runs + # ensure_watcher_in_raft to add the watcher to the second cluster's Raft + async with ops_test.fast_forward(): + # Wait for the watcher to connect to both clusters + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME, second_pg_app, WATCHER_APP_NAME], + status="active", + timeout=600, + ) + + # Verify both Raft clusters have the watcher as a member + # Check first cluster + await verify_raft_cluster_health( + ops_test, DATABASE_APP_NAME, WATCHER_APP_NAME, expected_members=3 + ) + # Check second cluster + await verify_raft_cluster_health( + ops_test, second_pg_app, WATCHER_APP_NAME, expected_members=3 + ) + + finally: + # Clean up the second cluster relation and app + if second_pg_app in ops_test.model.applications: + await ops_test.model.remove_application( + second_pg_app, block_until_done=True, force=True + ) + + # Verify original watcher is still healthy after removing the second cluster + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME, WATCHER_APP_NAME], + status="active", + timeout=300, + ) + + +@pytest.mark.abort_on_fail +async def test_watcher_production_profile_az_blocked(ops_test: OpsTest, charm) -> None: + """Test watcher with profile=production blocks on AZ co-location. + + When all units are in the same availability zone (common on single-host + LXD deployments), a watcher with profile=production should enter + BlockedStatus because it shares an AZ with the PostgreSQL units. + This validates the AZ enforcement behavior. + + If JUJU_AVAILABILITY_ZONE is not set (some CI environments), the watcher + should reach active status since no AZ co-location can be detected. + + Since watcher-offer has limit: 1, we must remove the existing testing watcher + before deploying the production one, then restore it afterward. + """ + pytest.skip("Needs both sides of the relation") + return + production_watcher = "pg-watcher-prod" + + # Remove existing watcher to free the watcher-offer relation slot + logger.info("Removing existing testing watcher to free relation slot") + if WATCHER_APP_NAME in ops_test.model.applications: + await ops_test.model.remove_application( + WATCHER_APP_NAME, block_until_done=True, force=True + ) + + try: + # Deploy a production-profile watcher + logger.info("Deploying watcher with profile=production") + await ops_test.model.deploy( + charm, + application_name=production_watcher, + num_units=1, + series="noble", + config={"role": "watcher", "profile": "production"}, + ) + + # Wait for initial install + await ops_test.model.wait_for_idle( + apps=[production_watcher], + timeout=600, + raise_on_error=False, + ) + + # Relate to the existing PostgreSQL cluster + await ops_test.model.integrate( + f"{DATABASE_APP_NAME}:watcher-offer", f"{production_watcher}:watcher" + ) + + # Wait for the watcher to settle (it may block or go active depending on AZ) + async with ops_test.fast_forward(): + await ops_test.model.wait_for_idle( + apps=[production_watcher], + timeout=600, + raise_on_error=False, + ) + + # Check the watcher's status + watcher_unit = ops_test.model.applications[production_watcher].units[0] + status = watcher_unit.workload_status + status_msg = watcher_unit.workload_status_message + + if status == "blocked": + # AZ is set and co-located — expected on single-host deployments + assert "AZ co-location" in status_msg, ( + f"Blocked status should mention AZ co-location, got: {status_msg}" + ) + logger.info(f"Production watcher correctly blocked: {status_msg}") + elif status == "active": + # AZ is not set — no co-location detected, watcher is active + logger.info("JUJU_AVAILABILITY_ZONE not set, watcher is active (no AZ enforcement)") + else: + pytest.fail( + f"Unexpected watcher status: {status} - {status_msg}. " + "Expected 'blocked' (AZ co-location) or 'active' (no AZ)." + ) + + finally: + # Clean up production watcher + if production_watcher in ops_test.model.applications: + await ops_test.model.remove_application( + production_watcher, block_until_done=True, force=True + ) + + # Restore the original testing watcher + logger.info("Restoring original testing watcher") + await ops_test.model.deploy( + charm, + application_name=WATCHER_APP_NAME, + num_units=1, + series="noble", + config={"role": "watcher", "profile": "testing"}, + ) + await ops_test.model.wait_for_idle( + apps=[WATCHER_APP_NAME], + timeout=600, + raise_on_error=False, + ) + await ops_test.model.integrate( + f"{DATABASE_APP_NAME}:watcher-offer", f"{WATCHER_APP_NAME}:watcher" + ) + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME, WATCHER_APP_NAME], + status="active", + timeout=600, + ) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 0000000..ea804cd --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import itertools +import json +import logging +from pathlib import Path + +import psycopg2 +import requests +import yaml +from constants import PEER +from juju.model import Model +from pytest_operator.plugin import OpsTest +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, +) + +CHARM_BASE = "ubuntu@22.04" +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +DATABASE_APP_NAME = METADATA["name"] +APPLICATION_NAME = "postgresql-test-app" +DATA_INTEGRATOR_APP_NAME = "data-integrator" +DATABASE_DEFAULT_NAME = "postgres" + + +class SecretNotFoundError(Exception): + """Raised when a secret is not found.""" + + +logger = logging.getLogger(__name__) + + +def get_patroni_cluster(unit_ip: str) -> dict[str, str]: + resp = requests.get(f"https://{unit_ip}:8008/cluster", verify=False) + return resp.json() + + +def db_connect( + host: str, password: str, username: str = "operator", database: str = "postgres" +) -> psycopg2.extensions.connection: + """Returns psycopg2 connection object linked to postgres db in the given host. + + Args: + host: the IP of the postgres host + password: user password + username: username to connect with + database: database to connect to + + Returns: + psycopg2 connection object linked to postgres db, under "operator" user. + """ + return psycopg2.connect( + f"dbname='{database}' user='{username}' host='{host}' password='{password}' connect_timeout=10" + ) + + +async def execute_query_on_unit( + unit_address: str, + password: str, + query: str, + database: str = DATABASE_DEFAULT_NAME, + sslmode: str | None = None, +): + """Execute given PostgreSQL query on a unit. + + Args: + unit_address: The public IP address of the unit to execute the query on. + password: The PostgreSQL superuser password. + query: Query to execute. + database: Optional database to connect to (defaults to postgres database). + sslmode: Optional ssl mode to use (defaults to None). + + Returns: + A list of rows that were potentially returned from the query. + """ + extra_connection_parameters = f"sslmode={sslmode}" if sslmode else "" + with ( + psycopg2.connect( + f"dbname='{database}' user='operator' host='{unit_address}'" + f"password='{password}' connect_timeout=10 {extra_connection_parameters}" + ) as connection, + connection.cursor() as cursor, + ): + cursor.execute(query) + output = list(itertools.chain(*cursor.fetchall())) + return output + + +async def get_machine_from_unit(ops_test: OpsTest, unit_name: str) -> str: + """Get the name of the machine from a specific unit. + + Args: + ops_test: The ops test framework instance + unit_name: The name of the unit to get the machine + + Returns: + The name of the machine. + """ + raw_hostname = await run_command_on_unit(ops_test, unit_name, "hostname") + return raw_hostname.strip() + + +async def get_password( + ops_test: OpsTest, + username: str = "operator", + database_app_name: str = DATABASE_APP_NAME, +) -> str: + """Retrieve a user password from the secret. + + Args: + ops_test: ops_test instance. + username: the user to get the password. + database_app_name: the app for getting the secret + + Returns: + the user password. + """ + secret = await get_secret_by_label(ops_test, label=f"{PEER}.{database_app_name}.app") + password = secret.get(f"{username}-password") + + return password + + +async def get_secret_by_label(ops_test: OpsTest, label: str) -> dict[str, str]: + secrets_raw = await ops_test.juju("list-secrets") + secret_ids = [ + secret_line.split()[0] for secret_line in secrets_raw[1].split("\n")[1:] if secret_line + ] + + for secret_id in secret_ids: + secret_data_raw = await ops_test.juju( + "show-secret", "--format", "json", "--reveal", secret_id + ) + secret_data = json.loads(secret_data_raw[1]) + + if label == secret_data[secret_id].get("label"): + return secret_data[secret_id]["content"]["Data"] + + raise SecretNotFoundError(f"Secret with label {label} not found") + + +@retry( + stop=stop_after_attempt(10), + wait=wait_exponential(multiplier=1, min=2, max=30), + reraise=True, +) +def get_unit_address(ops_test: OpsTest, unit_name: str, model: Model = None) -> str: + """Get unit IP address. + + Args: + ops_test: The ops test framework instance + unit_name: The name of the unit + model: Optional model to use to get the unit address + + Returns: + IP address of the unit + """ + if model is None: + model = ops_test.model + return model.units.get(unit_name).public_address + + +def has_relation_exited( + ops_test: OpsTest, endpoint_one: str, endpoint_two: str, model: Model = None +) -> bool: + """Returns true if the relation between endpoint_one and endpoint_two has been removed.""" + relations = model.relations if model is not None else ops_test.model.relations + for rel in relations: + endpoints = [endpoint.name for endpoint in rel.endpoints] + if endpoint_one in endpoints and endpoint_two in endpoints: + return False + return True + + +async def run_command_on_unit(ops_test: OpsTest, unit_name: str, command: str) -> str: + """Run a command on a specific unit. + + Args: + ops_test: The ops test framework instance + unit_name: The name of the unit to run the command on + command: The command to run + + Returns: + the command output if it succeeds, otherwise raises an exception. + """ + complete_command = ["exec", "--unit", unit_name, "--", *command.split()] + return_code, stdout, _ = await ops_test.juju(*complete_command) + if return_code != 0: + logger.error(stdout) + raise Exception( + f"Expected command '{command}' to succeed instead it failed: {return_code}" + ) + return stdout diff --git a/tests/integration/pyproject.toml b/tests/integration/pyproject.toml new file mode 100644 index 0000000..f2bc1ab --- /dev/null +++ b/tests/integration/pyproject.toml @@ -0,0 +1,72 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +# Linting tools configuration +[tool.ruff] +# preview and explicit preview are enabled for CPY001 +preview = true +target-version = "py310" +src = ["."] +line-length = 99 + +[tool.ruff.lint] +explicit-preview-rules = true +select = [ + "A", + "E", + "W", + "F", + "C", + "N", + "D", + "I001", + "B", + "CPY001", + "RUF", + "S", + "SIM", + "UP", + "TC", +] +extend-ignore = [ + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", + "B904", +] +# Ignore E501 because using black creates errors with this +# Ignore D107 Missing docstring in __init__ +ignore = ["E501", "D107"] + +[tool.ruff.lint.per-file-ignores] +"*" = [ + "D100", + "D101", + "D102", + "D103", + "D104", + # Asserts + "B011", + # Disable security checks for tests + "S", +] + +[tool.ruff.lint.flake8-copyright] +# Check for properly formatted copyright header in each file +author = "Canonical Ltd." +notice-rgx = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+" +min-file-size = 1 + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/tests/spread/test_stereo_mode.py/task.yaml b/tests/spread/test_stereo_mode.py/task.yaml new file mode 100644 index 0000000..65ce3cf --- /dev/null +++ b/tests/spread/test_stereo_mode.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_stereo_mode.py +environment: + TEST_MODULE: ha_tests/test_stereo_mode.py +execute: | + tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results" +artifacts: + - allure-results diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..34345c8 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +import pathlib +import platform +import shutil +from unittest.mock import PropertyMock + +import pytest +import tomli +import tomli_w + + +# This causes every test defined in this file to run 2 times, each with +# ops.JujuVersion.has_secrets set as True or as False +@pytest.fixture(autouse=True) +def _has_secrets(request, monkeypatch): + monkeypatch.setattr("ops.JujuVersion.has_secrets", PropertyMock(return_value=True)) + + +class _MockRefresh: + in_progress = False + next_unit_allowed_to_refresh = True + workload_allowed_to_start = True + app_status_higher_priority = None + unit_status_higher_priority = None + + def __init__(self, _, /): + pass + + def update_snap_revision(self): + pass + + @property + def pinned_snap_revision(self): + with pathlib.Path("refresh_versions.toml").open("rb") as file: + return tomli.load(file)["snap"]["revisions"][platform.machine()] + + def unit_status_lower_priority(self, *, workload_is_running=True): + return None + + +@pytest.fixture(autouse=True) +def patch(monkeypatch): + monkeypatch.setattr("charm_refresh.Machines", _MockRefresh) + + # Add charm version to refresh_versions.toml + path = pathlib.Path("refresh_versions.toml") + backup = pathlib.Path("refresh_versions.toml.backup") + shutil.copy(path, backup) + with path.open("rb") as file: + versions = tomli.load(file) + versions["charm"] = "16/0.0.0" + with path.open("wb") as file: + tomli_w.dump(versions, file) + + yield + + path.unlink() + shutil.move(backup, path) diff --git a/tests/unit/test_raft_controller.py b/tests/unit/test_raft_controller.py new file mode 100644 index 0000000..3baa305 --- /dev/null +++ b/tests/unit/test_raft_controller.py @@ -0,0 +1,84 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from jinja2 import Template +from pytest import fixture + +from raft_controller import SERVICE_FILE, RaftController, install_service + + +@fixture +def controller(tmp_path: Path) -> RaftController: + controller = RaftController(MagicMock(), instance_id="rel42") + controller.data_dir = str(tmp_path / "watcher-raft" / "rel42") + controller.config_file = str(tmp_path / "watcher-raft" / "rel42" / "patroni-raft.yaml") + controller.service_name = "watcher-raft-rel42" + controller.service_file = str(tmp_path / "watcher-raft-rel42.service") + return controller + + +def test_configure(tmp_path: Path, controller: RaftController): + with open("templates/watcher.yml.j2") as file: + contents = file.read() + template = Template(contents) + + expected_content = template.render( + self_addr="10.0.0.1", + self_port=2222, + partner_addrs=["10.0.0.2"], + password="secret", + data_dir=f"{tmp_path}/watcher-raft/rel42", + ) + with ( + patch("raft_controller.render_file") as _render_file, + patch("raft_controller.create_directory") as _create_directory, + ): + assert controller.configure(2222, "10.0.0.1", ["10.0.0.2"], "secret") + + assert _create_directory.call_count == 2 + _create_directory.assert_any_call(f"{tmp_path}/watcher-raft/rel42", 0o700) + _create_directory.assert_any_call(f"{tmp_path}/watcher-raft/rel42/raft", 0o700) + _render_file.assert_called_once_with( + f"{tmp_path}/watcher-raft/rel42/patroni-raft.yaml", expected_content, 0o600 + ) + + +def test_remove_service_disables_unit_and_deletes_dir(tmp_path: Path, controller: RaftController): + Path(controller.service_file).write_text("[Unit]\nDescription=test\n") + + with ( + patch("raft_controller.service_running") as _service_running, + patch("raft_controller.service_stop") as _service_stop, + patch("raft_controller.service_disable") as _service_disable, + patch("raft_controller.rmtree") as _rmtree, + ): + assert controller.remove_service() + _service_running.assert_called_once_with(controller.service_name) + _service_stop.assert_called_once_with(controller.service_name) + _service_disable.assert_called_once_with(controller.service_name) + _rmtree.assert_called_once_with(controller.data_dir) + + +def test_install_service_uses_patroni_profile_execstart( + tmp_path: Path, controller: RaftController +): + with open("templates/watcher.service.j2") as file: + contents = file.read() + template = Template(contents) + + expected_content = template.render( + config_file="/var/snap/charmed-postgresql/common/watcher-raft" + ) + + with ( + patch("raft_controller.daemon_reload") as _daemon_reload, + patch("raft_controller.render_file") as _render_file, + patch("raft_controller.create_directory"), + ): + install_service() + + _render_file.assert_called_once_with(SERVICE_FILE, expected_content, 0o644, change_owner=False) + _daemon_reload.assert_called_once_with() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..abbb6f6 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,47 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +from unittest.mock import mock_open, patch + +from utils import render_file + + +def test_render_file(): + with ( + patch("os.chmod") as _chmod, + patch("os.chown") as _chown, + patch("pwd.getpwnam") as _pwnam, + patch("tempfile.NamedTemporaryFile") as _temp_file, + ): + # Set a mocked temporary filename. + filename = "/tmp/temporaryfilename" + _temp_file.return_value.name = filename + # Setup a mock for the `open` method. + mock = mock_open() + # Patch the `open` method with our mock. + with patch("builtins.open", mock, create=True): + # Set the uid/gid return values for lookup of 'postgres' user. + _pwnam.return_value.pw_uid = 35 + _pwnam.return_value.pw_gid = 35 + # Call the method using a temporary configuration file. + render_file(filename, "rendered-content", 0o640) + + # Check the rendered file is opened with "w+" mode. + assert mock.call_args_list[0][0] == (filename, "w+") + # Ensure that the correct user is lookup up. + _pwnam.assert_called_with("_daemon_") + # Ensure the file is chmod'd correctly. + _chmod.assert_called_with(filename, 0o640) + # Ensure the file is chown'd correctly. + _chown.assert_called_with(filename, uid=35, gid=35) + + # Test when it's requested to not change the file owner. + mock.reset_mock() + _pwnam.reset_mock() + _chmod.reset_mock() + _chown.reset_mock() + with patch("builtins.open", mock, create=True): + render_file(filename, "rendered-content", 0o640, change_owner=False) + _pwnam.assert_not_called() + _chmod.assert_called_once_with(filename, 0o640) + _chown.assert_not_called() diff --git a/tests/unit/test_watcher_requirer.py b/tests/unit/test_watcher_requirer.py new file mode 100644 index 0000000..7ddacd5 --- /dev/null +++ b/tests/unit/test_watcher_requirer.py @@ -0,0 +1,309 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for the watcher requirer relation handler (AZ co-location logic).""" + +from unittest.mock import MagicMock, patch + +from ops import ActiveStatus, BlockedStatus, WaitingStatus + +from src.relations.watcher_requirer import WatcherRequirerHandler + + +def create_mock_charm(profile="testing"): + """Create a mock charm for watcher requirer testing.""" + mock_charm = MagicMock() + mock_charm.config = MagicMock() + mock_charm.config.profile = profile + mock_charm.unit.name = "pg-watcher/0" + return mock_charm + + +def create_mock_relation(units_with_az=None): + """Create a mock relation with units that have AZ data. + + Args: + units_with_az: Dict mapping unit names to their AZ values. + Example: {"postgresql/0": "az1", "postgresql/1": "az2"} + """ + mock_relation = MagicMock() + mock_relation.id = 42 + + if units_with_az is None: + units_with_az = {} + + mock_units = [] + mock_data = {} + for unit_name, az in units_with_az.items(): + mock_unit = MagicMock() + mock_unit.name = unit_name + mock_units.append(mock_unit) + unit_data = {} + if az is not None: + unit_data["unit-az"] = az + mock_data[mock_unit] = unit_data + + mock_relation.units = set(mock_units) + mock_relation.app = MagicMock() + mock_relation.app.name = "postgresql" + mock_data[mock_relation.app] = {} + mock_relation.data = mock_data + return mock_relation + + +class TestAZColocation: + """Tests for AZ co-location detection and enforcement.""" + + def test_check_az_colocation_no_az_set(self): + """No warning when JUJU_AVAILABILITY_ZONE is not set.""" + mock_charm = create_mock_charm() + relation = create_mock_relation({"postgresql/0": "az1"}) + + with patch.object(WatcherRequirerHandler, "__init__", return_value=None): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + + with patch.dict("os.environ", {}, clear=True): + result = handler._check_az_colocation(relation) + assert result is None + + def test_check_az_colocation_different_az(self): + """No warning when watcher is in a different AZ.""" + mock_charm = create_mock_charm() + relation = create_mock_relation({"postgresql/0": "az1", "postgresql/1": "az2"}) + + with patch.object(WatcherRequirerHandler, "__init__", return_value=None): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + + with patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az3"}, clear=False): + result = handler._check_az_colocation(relation) + assert result is None + + def test_check_az_colocation_same_az(self): + """Warning returned when watcher shares AZ with a PostgreSQL unit.""" + mock_charm = create_mock_charm() + relation = create_mock_relation({"postgresql/0": "az1", "postgresql/1": "az2"}) + + with patch.object(WatcherRequirerHandler, "__init__", return_value=None): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + + with patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az1"}, clear=False): + result = handler._check_az_colocation(relation) + assert result is not None + assert "az1" in result + assert "postgresql/0" in result + + def test_check_az_colocation_multiple_colocated(self): + """Warning lists all co-located units.""" + mock_charm = create_mock_charm() + relation = create_mock_relation({"postgresql/0": "az1", "postgresql/1": "az1"}) + + with patch.object(WatcherRequirerHandler, "__init__", return_value=None): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + + with patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az1"}, clear=False): + result = handler._check_az_colocation(relation) + assert result is not None + assert "postgresql/0" in result + assert "postgresql/1" in result + + def test_check_az_colocation_pg_unit_no_az(self): + """No warning when PostgreSQL unit has no AZ set.""" + mock_charm = create_mock_charm() + relation = create_mock_relation({"postgresql/0": None}) + + with patch.object(WatcherRequirerHandler, "__init__", return_value=None): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + + with patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az1"}, clear=False): + result = handler._check_az_colocation(relation) + assert result is None + + +class TestAZProfileEnforcement: + """Tests for profile-based AZ enforcement (testing=warning, production=blocked).""" + + def _setup_handler_with_relations(self, profile, watcher_az, pg_units_az): + """Create a handler with mocked relations for update_status testing. + + Args: + profile: "testing" or "production" + watcher_az: The watcher's AZ or None + pg_units_az: Dict of unit_name -> az for PostgreSQL units + """ + mock_charm = create_mock_charm(profile=profile) + mock_relation = create_mock_relation(pg_units_az) + + with patch.object(WatcherRequirerHandler, "__init__", return_value=None): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + + # Mock framework.model to make self.model work + mock_framework = MagicMock() + mock_framework.model = mock_charm.model + handler.framework = mock_framework + + # Mock model.relations + mock_charm.model.relations.get.return_value = [mock_relation] + + # Mock _get_pg_endpoints + handler._get_pg_endpoints = MagicMock(return_value=list(pg_units_az.keys())) + handler._update_unit_address_if_changed = MagicMock() + + return handler, mock_charm, watcher_az + + def test_testing_profile_same_az_sets_active_with_warning(self): + """With profile=testing and same AZ, status is Active with WARNING.""" + handler, mock_charm, _ = self._setup_handler_with_relations( + profile="testing", + watcher_az="az1", + pg_units_az={"postgresql/0": "az1", "postgresql/1": "az2"}, + ) + + with ( + patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az1"}, clear=False), + patch( + "relations.watcher_requirer.RaftController.get_status", + return_value={"connected": True}, + ), + ): + handler._on_update_status(MagicMock()) + + status = mock_charm.unit.status + assert isinstance(status, ActiveStatus), ( + f"Expected ActiveStatus, got {type(status)}: {status}" + ) + assert "WARNING" in status.message + + def test_production_profile_same_az_sets_blocked(self): + """With profile=production and same AZ, status is Blocked.""" + handler, mock_charm, _ = self._setup_handler_with_relations( + profile="production", + watcher_az="az1", + pg_units_az={"postgresql/0": "az1", "postgresql/1": "az2"}, + ) + + with ( + patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az1"}, clear=False), + patch( + "relations.watcher_requirer.RaftController.get_status", + return_value={"connected": True}, + ), + ): + handler._on_update_status(MagicMock()) + + status = mock_charm.unit.status + assert isinstance(status, BlockedStatus), ( + f"Expected BlockedStatus, got {type(status)}: {status}" + ) + assert "AZ co-location" in status.message + + def test_production_profile_different_az_sets_active(self): + """With profile=production and different AZ, status is Active (no block).""" + handler, mock_charm, _ = self._setup_handler_with_relations( + profile="production", + watcher_az="az3", + pg_units_az={"postgresql/0": "az1", "postgresql/1": "az2"}, + ) + + with ( + patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az3"}, clear=False), + patch( + "relations.watcher_requirer.RaftController.get_status", + return_value={"connected": True}, + ), + ): + handler._on_update_status(MagicMock()) + + status = mock_charm.unit.status + assert isinstance(status, ActiveStatus), ( + f"Expected ActiveStatus, got {type(status)}: {status}" + ) + assert "WARNING" not in status.message + + def test_no_az_no_block(self): + """When JUJU_AVAILABILITY_ZONE is not set, no blocking regardless of profile.""" + handler, mock_charm, _ = self._setup_handler_with_relations( + profile="production", + watcher_az=None, + pg_units_az={"postgresql/0": "az1", "postgresql/1": "az2"}, + ) + + env = {k: v for k, v in __import__("os").environ.items() if k != "JUJU_AVAILABILITY_ZONE"} + with ( + patch.dict("os.environ", env, clear=True), + patch( + "relations.watcher_requirer.RaftController.get_status", + return_value={"connected": True}, + ), + ): + handler._on_update_status(MagicMock()) + + status = mock_charm.unit.status + assert isinstance(status, ActiveStatus), ( + f"Expected ActiveStatus, got {type(status)}: {status}" + ) + + def test_no_raft_connection_sets_waiting(self): + """When Raft is not connected, status is Waiting regardless of AZ.""" + mock_charm = create_mock_charm(profile="production") + mock_relation = create_mock_relation({"postgresql/0": "az1"}) + + with ( + patch.object(WatcherRequirerHandler, "__init__", return_value=None), + patch("raft_controller.service_running") as _service_running, + ): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + handler._raft_controllers = {} + mock_framework = MagicMock() + mock_framework.model = mock_charm.model + handler.framework = mock_framework + mock_charm.model.relations.get.return_value = [mock_relation] + + mock_raft = MagicMock() + mock_raft.get_status.return_value = {"connected": False} + handler._raft_controllers[mock_relation.id] = mock_raft + handler._get_pg_endpoints = MagicMock(return_value=[]) + handler._update_unit_address_if_changed = MagicMock() + + with patch.dict("os.environ", {"JUJU_AVAILABILITY_ZONE": "az1"}, clear=False): + handler._on_update_status(MagicMock()) + + status = mock_charm.unit.status + assert isinstance(status, WaitingStatus) + + +class TestWatcherRelationLifecycle: + """Tests for watcher relation lifecycle cleanup.""" + + def test_relation_broken_removes_port(self): + """Relation-broken removes the Raft service and releases the allocated port.""" + mock_charm = create_mock_charm() + mock_relation = MagicMock() + mock_relation.id = 42 + mock_event = MagicMock() + mock_event.relation = mock_relation + + with ( + patch.object(WatcherRequirerHandler, "__init__", return_value=None), + patch("relations.watcher_requirer.RaftController.remove_service") as _remove_service, + ): + handler = WatcherRequirerHandler.__new__(WatcherRequirerHandler) + handler.charm = mock_charm + handler._release_port_for_relation = MagicMock() + + mock_framework = MagicMock() + mock_framework.model = mock_charm.model + handler.framework = mock_framework + + mock_charm.model.relations.get.return_value = [] + + handler._on_watcher_relation_broken(mock_event) + + _remove_service.assert_called_once_with() + handler._release_port_for_relation.assert_called_once_with(42) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6864dc7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,71 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +[tox] +no_package = True +env_list = lint, unit + +[vars] +src_path = "{tox_root}/src" +tests_path = "{tox_root}/tests" +all_path = {[vars]src_path} {[vars]tests_path} + +[testenv] +set_env = + PYTHONPATH = {tox_root}/lib:{tox_root}/src + PY_COLORS = 1 +allowlist_externals = + poetry + +[testenv:format] +description = Apply coding style standards to code +commands_pre = + poetry install --only format --no-root +commands = + poetry lock + poetry run ruff check --fix {[vars]all_path} + poetry run ruff format {[vars]all_path} + +[testenv:lint] +description = Check code against coding style standards +allowlist_externals = + {[testenv]allowlist_externals} + find +commands_pre = + poetry install --with lint,format --no-root +commands = + poetry check --lock + poetry run codespell {[vars]all_path} + poetry run ruff check {[vars]all_path} + poetry run ruff format --check --diff {[vars]all_path} + find {[vars]all_path} -type f \( -name "*.sh" -o -name "*.bash" \) -exec poetry run shellcheck --color=always \{\} + + # run last because it's slowest + poetry run ty check + +[testenv:unit] +description = Run unit tests +set_env = + {[testenv]set_env} +commands_pre = + poetry install --only main,charm-libs,unit --no-root +commands = + poetry run coverage run --source={[vars]src_path} \ + -m pytest -v --tb native -s {posargs} {[vars]tests_path}/unit + poetry run coverage report + poetry run coverage xml + +[testenv:integration] +description = Run integration tests +pass_env = + CI + AWS_ACCESS_KEY + AWS_SECRET_KEY + GCP_ACCESS_KEY + GCP_SECRET_KEY + UBUNTU_PRO_TOKEN + LANDSCAPE_ACCOUNT_NAME + LANDSCAPE_REGISTRATION_KEY +commands_pre = + poetry install --only integration --no-root +commands = + poetry run pytest -v --tb native --log-cli-level=INFO -s --ignore={[vars]tests_path}/unit/ {posargs}