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}